Compare commits

...

117 Commits

Author SHA1 Message Date
tim
f1c83b0f68 fix: 更新提示 2025-08-18 02:19:43 +08:00
tim
22c2b1564d feat: ui 优化+弹窗 2025-08-18 02:18:04 +08:00
tim
628d28c12d feat: 注册流程重构 2025-08-18 02:06:48 +08:00
Tim
2577992ee3 Merge pull request #613 from nagisa77/codex/implement-invitation-link-functionality
feat: add invite link generation and copy
2025-08-18 01:24:05 +08:00
Tim
5b837c9d7f feat: add invite link generation and copy 2025-08-18 01:23:33 +08:00
tim
017ad5bf54 feat: invite ui 2025-08-18 01:15:46 +08:00
Tim
f076b70e9b Merge pull request #612 from nagisa77/codex/add-invitejwt-for-generating-invitation-tokens
feat: add invite token support
2025-08-18 01:11:33 +08:00
Tim
62d12ad2a7 feat: track oauth new-user result 2025-08-18 01:11:16 +08:00
tim
923854bbc6 feat: 适配透传invite_code 2025-08-17 21:56:14 +08:00
tim
9ca5d7b167 feat: 各种登录方式传入invite_token 2025-08-17 12:45:58 +08:00
tim
9c3e1d17f0 Merge remote-tracking branch 'origin/main' into feature/coin_store 2025-08-17 12:09:26 +08:00
tim
7906062945 fix: 添加缺失route 2025-08-17 12:08:18 +08:00
tim
785c36d339 feat: 新增邀请页面ui 2025-08-17 11:51:16 +08:00
Tim
197cbca99c Merge pull request #609 from nagisa77/codex/add-invitation-code-points-event-3vhg3b
Add invite points activity
2025-08-17 11:38:34 +08:00
Tim
b1076d7256 Add invite points activity 2025-08-17 11:38:09 +08:00
tim
ce94cd7e73 feat: 积分禁止删除 2025-08-17 02:31:23 +08:00
Tim
90147d6cd9 Merge pull request #606 from nagisa77/codex/add-new-notification-type-for-points-exchange
feat: add point redeem notification type
2025-08-17 02:27:33 +08:00
Tim
2c187cf2cd feat: add point redeem notification 2025-08-17 02:27:19 +08:00
tim
0b6d4f9709 feat: 积分页面不足展示 2025-08-17 02:19:21 +08:00
Tim
cf3b6d8fc7 Merge pull request #605 from nagisa77/codex/update-points-mall-functionality
feat: add point mall redemption
2025-08-17 02:07:02 +08:00
Tim
8d98c876d2 feat: add point mall redemption 2025-08-17 02:06:47 +08:00
tim
df4df1933a feat: 积分页面ui 2025-08-17 01:57:42 +08:00
Tim
7507f1bb03 Merge pull request #604 from nagisa77/codex/add-hni7s1
feat: add point rules and products to points mall
2025-08-17 01:32:59 +08:00
Tim
9b4c36c76a feat: add point rules and products 2025-08-17 01:32:26 +08:00
Tim
edfc81aeb0 Merge pull request #603 from nagisa77/codex/add
feat: add point mall module
2025-08-17 01:24:06 +08:00
Tim
7bd1225b27 feat: add point mall module 2025-08-17 01:23:47 +08:00
Tim
2dd56e27af Merge pull request #599 from nagisa77/feature/daily_bugfix_0816
Feature/daily bugfix 0816
2025-08-17 01:13:39 +08:00
tim
c3ecef3609 feat: tooltip修改 2025-08-17 01:06:21 +08:00
Tim
efc74d0f77 Merge pull request #601 from nagisa77/codex/save-user-tab-selection-in-localstorage-4dcpd4
feat: remember home tab selection
2025-08-16 18:13:49 +08:00
Tim
f27cb5c703 feat: remember home tab selection 2025-08-16 18:13:37 +08:00
tim
a756c2fab3 feat: add 毛玻璃效果 + 开关 2025-08-16 18:11:56 +08:00
Tim
4e2171a8a6 Merge pull request #600 from nagisa77/codex/add-switch-for-frosted-glass-effect
Add frosted glass effect toggle
2025-08-16 17:58:01 +08:00
Tim
bcbdff8768 feat: initialize frosted glass setting 2025-08-16 17:57:42 +08:00
Tim
b976a1f46f Merge pull request #598 from nagisa77/codex/add-sub-tabs-to-personal-homepage-timeline
feat: add timeline filters on profile page
2025-08-16 16:21:57 +08:00
Tim
b9fd9711de feat: add timeline filters on profile page 2025-08-16 16:21:45 +08:00
tim
642a527dcf Revert "feat: persist home tab selection"
This reverts commit 2c5462cd97.
2025-08-16 16:20:52 +08:00
Tim
88afcc5a8e Merge pull request #597 from nagisa77/codex/save-user-tab-selection-in-localstorage-9dskt8
feat: persist home tab selection
2025-08-16 16:19:58 +08:00
Tim
2c5462cd97 feat: persist home tab selection 2025-08-16 16:19:44 +08:00
tim
2f29946b11 Revert "feat: remember selected tab"
This reverts commit 2322b2da15.
2025-08-16 16:19:23 +08:00
Tim
e27aa34cfd Merge pull request #596 from nagisa77/codex/save-user-tab-selection-in-localstorage
feat: persist home tab selection
2025-08-16 16:10:49 +08:00
Tim
2322b2da15 feat: remember selected tab 2025-08-16 16:10:37 +08:00
tim
79261054f9 feat: ci & cd 2025-08-16 15:24:32 +08:00
tim
86633e1f21 feat: ci & cd 2025-08-16 15:23:54 +08:00
tim
784598a6f0 feat: ci & cd 2025-08-16 15:23:05 +08:00
tim
fdad0e5d34 feat: cd & cd 2025-08-16 15:21:53 +08:00
tim
ebf63c4072 feat: test commit 2025-08-16 15:20:46 +08:00
tim
354d6bdaf9 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-16 15:19:21 +08:00
tim
d9aebdebdc feat: 预发环境 2025-08-16 15:19:10 +08:00
Tim
d6f6495b35 Merge pull request #595 from AnNingUI/main
fix: 修复我的信息界面中的header无法粘性布局的bug以及解决了一些-webkit样式警告
2025-08-16 15:02:48 +08:00
AnNingUI
300f8705ef fix: 修复我的信息界面中的header无法粘性布局的bug以及解决了一些-webkit样式警告
fixed: #588
2025-08-16 13:49:24 +08:00
tim
1f74a29dce fix: 修复header 显示异常 2025-08-16 11:34:03 +08:00
Tim
27ef792b11 Merge pull request #594 from immortal521/feat/user-menu-animation
feat: add transition effects for page and dropdown
2025-08-16 11:25:45 +08:00
Tim
8dd2d59617 Merge pull request #593 from immortal521/fix/mobile-theme-toggle-position
fix: incorrect animation start position on mobile theme toggle
2025-08-16 11:25:06 +08:00
Tim
077ba448d7 Merge pull request #592 from immortal521/fix/unlogin-cant-change-theme
fix: allow theme toggle without requiring user login
2025-08-16 11:22:36 +08:00
immortal521
9ce85f2769 fix: fix incorrect animation start position on mobile theme toggle
- Unified coordinate handling for mouse and touch events to ensure the
animation start point accurately follows the finger position on mobile
devices.
2025-08-16 01:45:57 +08:00
immortal521
f5557cbf08 feat: add transition effects for page and dropdown
- Add page transition CSS with opacity and blur effects

- Wrap dropdown in Transition component with slide effect

- Configure Nuxt pageTransition in config
2025-08-16 01:22:56 +08:00
immortal521
e042c499e1 fix: allow theme toggle without requiring user login 2025-08-16 01:11:30 +08:00
tim
e01afb168c Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-16 01:11:14 +08:00
tim
c1d81eb1d1 fix: update staging base url 2025-08-16 01:11:02 +08:00
Tim
2b0b429866 Merge pull request #589 from AnNingUI/main
fix: 添加对非startViewTransition支持的浏览器添加一个回退的主题切换动画
2025-08-16 00:34:08 +08:00
AnNingUI
8ea85d78ee fix: 解决menu-background-color变量被firefox的userChrome.css覆盖问题 2025-08-15 22:54:49 +08:00
AnNingUI
3b506fe8a8 Merge branch 'main' of github.com:AnNingUI/OpenIsle 2025-08-15 22:25:40 +08:00
AnNingUI
3cc7a4c01a fix: 添加对非startViewTransition支持的浏览器添加一个回退的主题切换动画
fixes: #583
2025-08-15 22:25:02 +08:00
tim
2e749a5672 fix: update 后端端口 2025-08-15 18:16:10 +08:00
tim
7d553d7750 fix: add staging example file 2025-08-15 17:50:20 +08:00
Tim
16105cef54 Merge pull request #584 from CH-122/fix/mobile-theme-mode
fix: 手机状态栏暗黑模式背景颜色显示不正确
2025-08-15 15:48:09 +08:00
CH-122
2b824d94f2 fix: 更新新增帖子图标类名并调整样式作用域 2025-08-15 15:47:24 +08:00
CH-122
00d3c563e2 feat: 移动端 header 中添加主题切换图标, 菜单中隐藏 2025-08-15 15:36:25 +08:00
CH-122
b26891261c fix: 适配 ios safari 浏览器暗黑模式 2025-08-15 15:23:49 +08:00
CH-122
c1d19b854b fix: 手机状态栏暗黑模式背景颜色显示不正确 2025-08-15 14:52:30 +08:00
Tim
72e7ccf262 Merge pull request #581 from immortal521/feat/theme-toggle-transition
feat: implement theme transition animations and dark mode improvements
2025-08-15 13:27:44 +08:00
tim
84ca6fd28c feat: add refresh home 2025-08-15 13:24:00 +08:00
immortal521
d1c148c5c4 Merge branch 'main' into feat/theme-toggle-transition 2025-08-15 13:20:37 +08:00
immortal521
ef58630dae feat: implement theme transition animations and dark mode improvements
- Add view transition API for theme switching

- Update cycleTheme to handle animation circle

- Refactor CSS with consistent quoting and indentation

- Improve theme variable handling and no-op optimizations

- Pass event to cycleTheme in MenuComponent
2025-08-15 13:12:27 +08:00
Tim
f025e82e7c Merge pull request #580 from nagisa77/codex/resolve-chunk-size-warning-issue
chore: split large vite chunks
2025-08-15 13:11:00 +08:00
Tim
4380a988f7 chore: split large vite chunks 2025-08-15 13:10:47 +08:00
tim
2899f7af48 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-15 13:04:48 +08:00
tim
d4b05256a3 fix: update package-lock 2025-08-15 13:03:43 +08:00
Tim
57a26e375d Merge pull request #579 from palmcivet/docs/update-readme
feat: 更新 README “开发”章节
2025-08-15 12:57:05 +08:00
Palm Civet
8a202c4fba feat: 更新 README 2025-08-15 12:39:51 +08:00
Tim
089b2a3f5f Merge pull request #578 from AnNingUI/main
feat: Add Messages Update
2025-08-15 12:26:40 +08:00
AnNingUI
0b3d7a21d5 fix: 迁移markAllRead函数 2025-08-15 11:59:29 +08:00
AnNingUI
fe8a705a28 Merge branch 'main' of github.com:AnNingUI/OpenIsle 2025-08-15 11:44:19 +08:00
AnNingUI
974c7ba83e feat: Add Message Update 2025-08-15 11:42:39 +08:00
Tim
f2937d735d Merge pull request #576 from nagisa77/feature/ui_fix_v0
fix: 移动端才显示
2025-08-15 11:40:21 +08:00
Tim
423248c574 fix: 移动端才显示 2025-08-15 11:39:47 +08:00
Tim
5126cfda8c Merge pull request #575 from nagisa77/feature/ui_fix_v0
fix: 仅仅在主页显示
2025-08-15 11:38:07 +08:00
Tim
e009875797 fix: 仅仅在主页显示 2025-08-15 11:37:30 +08:00
Tim
04ff17f796 Merge pull request #574 from nagisa77/feature/ui_fix_v0
fix: ui fix
2025-08-15 11:25:45 +08:00
Tim
e9c9fbd742 fix: ui fix 2025-08-15 11:24:01 +08:00
Tim
b385945c2d Merge pull request #572 from CH-122/refactor/ui
refactor: 在 header 组件中添加发帖功能,移动端添加发帖悬浮按钮,优化首页搜索标题样式 ,
2025-08-15 11:16:31 +08:00
CH-122
24cbed2eda feat: 移动端添加发帖悬浮按钮 2025-08-15 10:59:29 +08:00
CH-122
ba073b71a6 feat: 在头部组件和菜单组件中添加发帖功能,并优化首页搜索标题样式 2025-08-15 10:37:51 +08:00
CH-122
5ff098ea21 feat: 添加 Tooltip 组件 2025-08-15 10:31:53 +08:00
Tim
f6713b956e Merge pull request #569 from immortal521/fix/564-theme-toggle-btn-position 2025-08-15 09:27:55 +08:00
Tim
b8ea12646f Merge pull request #568 from immortal521/fix/about-page-link-color-#566 2025-08-15 09:27:14 +08:00
immortal521
e573e54c2b fix: correct theme toggle button position (#564) 2025-08-15 03:00:57 +08:00
immortal521
8ec005d392 fix(about): fix link color issue on about page (#566)
Questions:
- Why are markdown styles split into `about-content` and
`info-content-text`?
- Why is `about-content` defined both globally and inside the Vue
component?
2025-08-15 02:42:04 +08:00
tim
b1f92f61a6 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-15 01:37:01 +08:00
tim
824b4dd8aa feat: ui update 2025-08-15 01:36:50 +08:00
Tim
6b08db7e58 Merge pull request #565 from nagisa77/feature/daily_bugfix_0814
fix: revert vditor change
2025-08-15 00:51:09 +08:00
tim
6f3830b3f7 fix: revert vditor change 2025-08-15 00:50:44 +08:00
Tim
d70dad723f Merge pull request #563 from nagisa77/feature/daily_bugfix_0814
若干问题修复,见评论
2025-08-15 00:31:46 +08:00
tim
2cf89e4802 fix: ssr 水合采用useAsyncData 2025-08-15 00:12:06 +08:00
tim
1fc6460ae0 fix: 修复vditor移动端贴顶的问题 2025-08-15 00:01:18 +08:00
Tim
a04e5c2f6f Merge pull request #560 from CH-122/feat/password-recovery-hint
feat: 忘记密码页面添加提示 & 修复缺少定义导致的报错 #535
2025-08-14 23:43:26 +08:00
Tim
77b26937f5 Merge pull request #562 from CH-122/fix/mobile-header-search
fix: 移动端 header 点击搜索图标功能异常
2025-08-14 23:39:19 +08:00
Tim
a1134b9d4b Merge pull request #559 from AnNingUI/main 2025-08-14 21:42:32 +08:00
AnNingUI
600f6ac1d1 fix: 修复代码高亮背景与抽奖背景色公用的问题 2025-08-14 21:39:39 +08:00
CH_122
9ad50b35c9 fix: 移动端 header 点击搜索图标功能异常 2025-08-14 21:35:57 +08:00
CH_122
867ee3907b feat: 忘记密码添加提示 & 修复缺少定义导致的报错 2025-08-14 21:21:34 +08:00
CH_122
58fcd42745 style: add cursor pointer to dropdown items for better UX 2025-08-14 21:20:23 +08:00
AnNingUI
0ee62a3a04 fix: 让代码展示背景的样式更加现代化,修复分类选择框仅有一个当前分类的问题
Fixes #558
2025-08-14 21:05:08 +08:00
Tim
f0bc7a22a0 fix: google login 问题修复 2025-08-14 20:34:21 +08:00
Tim
f6c0c8e226 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 20:25:33 +08:00
Tim
8f3c0d6710 fix: google login 问题修复 2025-08-14 20:25:09 +08:00
Tim
4f738778db Merge pull request #557 from nagisa77/feature/code_buauty
fix: 代码风格设置
2025-08-14 20:17:23 +08:00
78 changed files with 6494 additions and 2588 deletions

23
.github/workflows/deploy-staging.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Staging CI & CD
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: Deploy
steps:
- uses: actions/checkout@v4
- name: Deploy to Server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: root
key: ${{ secrets.SSH_KEY }}
script: bash /opt/openisle/deploy-staging.sh

View File

@@ -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,22 +13,6 @@ 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:

View File

@@ -10,7 +10,7 @@
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
## 🚀 部署
## 🚧 开发
### 后端
@@ -20,9 +20,26 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
### 前端
1. `cd open-isle-cli`
2. 执行 `npm install`
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
1. 进入前端目录
```bash
cd frontend_nuxt
```
2. 安装依赖
```bash
npm install
```
3. 启动开发服务
```bash
npm run dev
```
生产版本使用如下命令编译:
```bash
npm run build
```
会在 `.output` 目录生成文件,配合线上网站方式部署
## ✨ 项目特点

View File

@@ -3,6 +3,12 @@ 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>
@@ -30,4 +36,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

View File

@@ -6,6 +6,8 @@ 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
@@ -22,5 +24,16 @@ 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);
}
}
}

View File

@@ -0,0 +1,31 @@
package com.openisle.config;
import com.openisle.model.PointGood;
import com.openisle.repository.PointGoodRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
/** Initialize default point mall goods. */
@Component
@RequiredArgsConstructor
public class PointGoodInitializer implements CommandLineRunner {
private final PointGoodRepository pointGoodRepository;
@Override
public void run(String... args) {
if (pointGoodRepository.count() == 0) {
PointGood g1 = new PointGood();
g1.setName("GPT Plus 1 个月");
g1.setCost(20000);
g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png");
pointGoodRepository.save(g1);
PointGood g2 = new PointGood();
g2.setName("奶茶");
g2.setCost(5000);
g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
pointGoodRepository.save(g2);
}
}
}

View File

@@ -75,14 +75,16 @@ 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.70",
"http://192.168.7.70:8080",
"http://192.168.7.98",
"http://192.168.7.98:3000",
websiteUrl,
websiteUrl.replace("://www.", "://")
));
@@ -117,6 +119,8 @@ 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")
@@ -149,6 +153,7 @@ 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 ")) {

View File

@@ -29,6 +29,7 @@ public class AuthController {
private final RegisterModeService registerModeService;
private final NotificationService notificationService;
private final UserRepository userRepository;
private final InviteService inviteService;
@Value("${app.captcha.enabled:false}")
@@ -45,6 +46,26 @@ 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());
@@ -58,10 +79,26 @@ public class AuthController {
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
if (ok) {
return ResponseEntity.ok(Map.of(
"message", "Verified",
"token", jwtService.generateReasonToken(req.getUsername())
));
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.badRequest().body(Map.of("error", "Invalid verification code"));
}
@@ -106,27 +143,42 @@ public class AuthController {
@PostMapping("/google")
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
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(user.get().getUsername())));
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"
));
}
if (!user.get().isApproved()) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(user.get().getUsername())
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(user.get().getUsername())
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid google token",
@@ -165,28 +217,44 @@ public class AuthController {
@PostMapping("/github")
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
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(user.get().getUsername())));
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"
));
}
if (!user.get().isApproved()) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
// 已填写注册理由
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(user.get().getUsername())
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(user.get().getUsername())
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid github code",
@@ -196,27 +264,43 @@ public class AuthController {
@PostMapping("/discord")
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
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(user.get().getUsername())));
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"
));
}
if (!user.get().isApproved()) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(user.get().getUsername())
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(user.get().getUsername())
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid discord code",
@@ -226,31 +310,44 @@ public class AuthController {
@PostMapping("/twitter")
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
Optional<User> user = twitterAuthService.authenticate(
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
req.getCode(),
req.getCodeVerifier(),
registerModeService.getRegisterMode(),
req.getRedirectUri());
if (user.isPresent()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
req.getRedirectUri(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
if (!user.get().isApproved()) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(user.get().getUsername())
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(user.get().getUsername())
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid twitter code",

View File

@@ -0,0 +1,23 @@
package com.openisle.controller;
import com.openisle.service.InviteService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/invite")
@RequiredArgsConstructor
public class InviteController {
private final InviteService inviteService;
@PostMapping("/generate")
public Map<String, String> generate(Authentication auth) {
String token = inviteService.generate(auth.getName());
return Map.of("token", token);
}
}

View File

@@ -0,0 +1,39 @@
package com.openisle.controller;
import com.openisle.dto.PointGoodDto;
import com.openisle.dto.PointRedeemRequest;
import com.openisle.mapper.PointGoodMapper;
import com.openisle.model.User;
import com.openisle.service.PointMallService;
import com.openisle.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** REST controller for point mall. */
@RestController
@RequestMapping("/api/point-goods")
@RequiredArgsConstructor
public class PointMallController {
private final PointMallService pointMallService;
private final UserService userService;
private final PointGoodMapper pointGoodMapper;
@GetMapping
public List<PointGoodDto> list() {
return pointMallService.listGoods().stream()
.map(pointGoodMapper::toDto)
.collect(Collectors.toList());
}
@PostMapping("/redeem")
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
return Map.of("point", point);
}
}

View File

@@ -7,4 +7,5 @@ import lombok.Data;
public class DiscordLoginRequest {
private String code;
private String redirectUri;
private String inviteToken;
}

View File

@@ -7,4 +7,5 @@ import lombok.Data;
public class GithubLoginRequest {
private String code;
private String redirectUri;
private String inviteToken;
}

View File

@@ -6,4 +6,5 @@ import lombok.Data;
@Data
public class GoogleLoginRequest {
private String idToken;
private String inviteToken;
}

View File

@@ -0,0 +1,12 @@
package com.openisle.dto;
import lombok.Data;
/** Point mall good info. */
@Data
public class PointGoodDto {
private Long id;
private String name;
private int cost;
private String image;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.dto;
import lombok.Data;
/** Request to redeem a point mall good. */
@Data
public class PointRedeemRequest {
private Long goodId;
private String contact;
}

View File

@@ -9,4 +9,5 @@ public class RegisterRequest {
private String email;
private String password;
private String captcha;
private String inviteToken;
}

View File

@@ -8,4 +8,5 @@ public class TwitterLoginRequest {
private String code;
private String redirectUri;
private String codeVerifier;
private String inviteToken;
}

View File

@@ -0,0 +1,18 @@
package com.openisle.mapper;
import com.openisle.dto.PointGoodDto;
import com.openisle.model.PointGood;
import org.springframework.stereotype.Component;
/** Mapper for point mall goods. */
@Component
public class PointGoodMapper {
public PointGoodDto toDto(PointGood good) {
PointGoodDto dto = new PointGoodDto();
dto.setId(good.getId());
dto.setName(good.getName());
dto.setCost(good.getCost());
dto.setImage(good.getImage());
return dto;
}
}

View File

@@ -3,5 +3,6 @@ package com.openisle.model;
/** Activity type enumeration. */
public enum ActivityType {
NORMAL,
MILK_TEA
MILK_TEA,
INVITE_POINTS
}

View File

@@ -0,0 +1,23 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDate;
/**
* Invite token entity tracking usage counts.
*/
@Data
@Entity
public class InviteToken {
@Id
private String token;
@ManyToOne
private User inviter;
private LocalDate createdDate;
private int usageCount;
}

View File

@@ -32,6 +32,8 @@ 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 */

View File

@@ -0,0 +1,26 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Item available in the point mall. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "point_goods")
public class PointGood {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int cost;
private String image;
}

View File

@@ -0,0 +1,12 @@
package com.openisle.repository;
import com.openisle.model.InviteToken;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
}

View File

@@ -0,0 +1,8 @@
package com.openisle.repository;
import com.openisle.model.PointGood;
import org.springframework.data.jpa.repository.JpaRepository;
/** Repository for point mall goods. */
public interface PointGoodRepository extends JpaRepository<PointGood, Long> {
}

View File

@@ -0,0 +1,12 @@
package com.openisle.service;
import com.openisle.model.User;
import lombok.Value;
/** Result for OAuth authentication indicating whether a new user was created. */
@Value
public class AuthResult {
User user;
boolean newUser;
}

View File

@@ -26,7 +26,7 @@ public class DiscordAuthService {
@Value("${discord.client-secret:}")
private String clientSecret;
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
try {
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));
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
@@ -82,7 +82,7 @@ public class DiscordAuthService {
user.setVerificationCode(null);
userRepository.save(user);
}
return user;
return new AuthResult(user, false);
}
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);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
}
return userRepository.save(user);
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -30,7 +30,7 @@ public class GithubAuthService {
@Value("${github.client-secret:}")
private String clientSecret;
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
try {
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));
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
@@ -101,7 +101,7 @@ public class GithubAuthService {
user.setVerificationCode(null);
userRepository.save(user);
}
return user;
return new AuthResult(user, false);
}
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);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(finalUsername));
}
return userRepository.save(user);
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -25,7 +25,7 @@ public class GoogleAuthService {
@Value("${google.client-id:}")
private String clientId;
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
.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));
return Optional.of(processUser(email, name, picture, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
@@ -53,8 +53,7 @@ public class GoogleAuthService {
user.setVerificationCode(null);
userRepository.save(user);
}
return user;
return new AuthResult(user, false);
}
User user = new User();
String baseUsername = email.split("@")[0];
@@ -68,12 +67,12 @@ public class GoogleAuthService {
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(username));
}
return userRepository.save(user);
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -0,0 +1,54 @@
package com.openisle.service;
import com.openisle.model.InviteToken;
import com.openisle.model.User;
import com.openisle.repository.InviteTokenRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class InviteService {
private final InviteTokenRepository inviteTokenRepository;
private final UserRepository userRepository;
private final JwtService jwtService;
private final PointService pointService;
public String generate(String username) {
User inviter = userRepository.findByUsername(username).orElseThrow();
LocalDate today = LocalDate.now();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) {
return existing.get().getToken();
}
String token = jwtService.generateInviteToken(username);
InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token);
inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken);
return token;
}
public boolean validate(String token) {
try {
jwtService.validateAndGetSubjectForInvite(token);
} catch (Exception e) {
return false;
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return invite != null && invite.getUsageCount() < 3;
}
public void consume(String token) {
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername());
}
}

View File

@@ -24,6 +24,9 @@ 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;
@@ -70,6 +73,17 @@ 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))
@@ -96,4 +110,13 @@ 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();
}
}

View File

@@ -141,6 +141,19 @@ public class NotificationService {
}
}
/**
* Create notifications for all admins when a user redeems a point good.
* Old redeem notifications from the same user are removed first.
*/
@org.springframework.transaction.annotation.Transactional
public void createPointRedeemNotifications(User user, String content) {
// notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
for (User admin : userRepository.findByRole(Role.ADMIN)) {
createNotification(admin, NotificationType.POINT_REDEEM, null, null,
null, user, null, content);
}
}
public List<NotificationPreferenceDto> listPreferences(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));

View File

@@ -0,0 +1,37 @@
package com.openisle.service;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.model.PointGood;
import com.openisle.model.User;
import com.openisle.repository.PointGoodRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/** Service for point mall operations. */
@Service
@RequiredArgsConstructor
public class PointMallService {
private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
public List<PointGood> listGoods() {
return pointGoodRepository.findAll();
}
public int redeem(User user, Long goodId, String contact) {
PointGood good = pointGoodRepository.findById(goodId)
.orElseThrow(() -> new NotFoundException("Good not found"));
if (user.getPoint() < good.getCost()) {
throw new FieldException("point", "Insufficient points");
}
user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
return user.getPoint();
}
}

View File

@@ -26,6 +26,11 @@ 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)

View File

@@ -33,11 +33,12 @@ public class TwitterAuthService {
@Value("${twitter.client-secret:}")
private String clientSecret;
public Optional<User> authenticate(
public Optional<AuthResult> authenticate(
String code,
String codeVerifier,
RegisterMode mode,
String redirectUri) {
String redirectUri,
boolean viaInvite) {
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
@@ -106,10 +107,10 @@ public class TwitterAuthService {
// Twitter v2 默认拿不到 email如果你申请到 email.scope可改用 /2/users/:id?user.fields=email
String email = username + "@twitter.com";
logger.debug("Processing user {} with email {}", username, email);
return Optional.of(processUser(email, username, avatar, mode));
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
}
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
@@ -119,7 +120,7 @@ public class TwitterAuthService {
userRepository.save(user);
}
logger.debug("Existing user {} authenticated", user.getUsername());
return user;
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
@@ -133,13 +134,13 @@ public class TwitterAuthService {
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
}
logger.debug("Creating new user {}", finalUsername);
return userRepository.save(user);
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -74,6 +74,13 @@ 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));
}

View File

@@ -10,6 +10,7 @@ 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

View File

@@ -144,6 +144,30 @@ 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);

View File

@@ -1,6 +1,15 @@
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
; 预发环境
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
; 正式环境/生产环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ

View File

@@ -0,0 +1,16 @@
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
; 预发环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
; 正式环境/生产环境
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ

View File

@@ -15,62 +15,77 @@
<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>
<script setup>
import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue'
import { useIsMobile } from '~/utils/screen'
export default {
name: 'App',
components: { HeaderComponent, MenuComponent, GlobalPopups },
setup() {
const isMobile = useIsMobile()
const 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 isMobile = useIsMobile()
const menuVisible = ref(!isMobile.value)
const header = useTemplateRef('header')
const showNewPostIcon = computed(() => useRoute().path === '/')
onMounted(() => {
if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768
}
})
const hideMenu = computed(() => {
return [
'/login',
'/signup',
'/404',
'/signup-reason',
'/github-callback',
'/twitter-callback',
'/discord-callback',
'/forgot-password',
'/google-callback',
].includes(useRoute().path)
})
const handleMenuOutside = (event) => {
const btn = header.value.$refs.menuBtn
if (btn && (btn === event.target || btn.contains(event.target))) {
return // 如果是菜单按钮的点击,不处理关闭
}
const header = useTemplateRef('header')
if (isMobile.value) {
menuVisible.value = false
}
}
onMounted(() => {
if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768
}
})
return { menuVisible, hideMenu, handleMenuOutside, header }
},
const handleMenuOutside = (event) => {
const btn = header.value.$refs.menuBtn
if (btn && (btn === event.target || btn.contains(event.target))) {
return // 如果是菜单按钮的点击,不处理关闭
}
if (isMobile.value) {
menuVisible.value = false
}
}
const goToNewPost = () => {
navigateTo('/new-post', { replace: false })
}
</script>
<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;
@@ -103,6 +118,24 @@ export default {
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 {

View File

@@ -2,27 +2,35 @@
--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;
--menu-background-color: white;
--blur-1: blur(1px);
--blur-2: blur(2px);
--blur-4: blur(4px);
--blur-5: blur(5px);
--blur-10: blur(10px);
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
--app-menu-background-color: white;
--background-color: white;
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
--background-color-blur: var(--background-color);
--background-color-blur: rgba(255, 255, 255, 0.57);
--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: rgb(241, 241, 241); */
--normal-background-color: white;
--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: 1200px;
--page-max-width: 1400px;
--page-max-width-mobile: 900px;
--article-info-background-color: #f0f0f0;
--activity-card-background-color: #fafafa;
@@ -33,8 +41,9 @@
--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;
--menu-background-color: #333;
--app-menu-background-color: #333;
--background-color: #333;
/* --background-color-blur: #333333a4; */
--background-color-blur: var(--background-color);
@@ -42,8 +51,10 @@
--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: #000000; */
--normal-background-color: #333;
--lottery-background-color: #4e4e4e;
--code-highlight-background-color: #262b35;
--login-background-color: #575757;
--login-background-color-hover: #717171;
--text-color: #eee;
@@ -52,6 +63,15 @@
--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;
@@ -132,7 +152,7 @@ body {
.info-content-text pre {
display: flex;
background-color: var(--lottery-background-color);
background-color: var(--code-highlight-background-color);
padding: 8px 12px;
border-radius: 4px;
line-height: 1.5;
@@ -164,7 +184,7 @@ body {
font-size: 13px;
border-radius: 4px;
white-space: no-wrap;
background-color: var(--lottery-background-color);
background-color: var(--code-highlight-background-color);
color: var(--text-color);
}
@@ -186,11 +206,13 @@ body {
opacity: 1;
}
.about-content a,
.info-content-text a {
color: var(--primary-color);
text-decoration: none;
}
.about-content a:hover,
.info-content-text a:hover {
text-decoration: underline;
}
@@ -297,6 +319,29 @@ body {
}
}
/* Transition API */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
[data-theme='dark']::view-transition-old(root) {
z-index: 2147483646;
}
[data-theme='dark']::view-transition-new(root) {
z-index: 1;
}
/* NProgress styles */
#nprogress {
pointer-events: none;

View File

@@ -41,8 +41,8 @@ export default {
left: 0;
right: 0;
bottom: 0;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: var(--blur-2);
-webkit-backdrop-filter: var(--blur-2);
}
.popup-content {
position: relative;

View File

@@ -114,7 +114,7 @@
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue'
import { computed, onMounted, ref, watch } 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(--menu-background-color);
background-color: var(--app-menu-background-color);
color: var(--text-color);
}
@@ -352,7 +352,7 @@ export default {
left: 0;
right: 0;
bottom: 0;
background-color: var(--menu-background-color);
background-color: var(--app-menu-background-color);
z-index: 1300;
display: flex;
flex-direction: column;

View File

@@ -3,22 +3,24 @@
<div class="dropdown-trigger" @click="toggle">
<slot name="trigger"></slot>
</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 }}
<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>
</div>
</Transition>
</div>
</template>
<script>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
export default {
name: 'DropdownMenu',
props: {
@@ -61,17 +63,28 @@ 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(--menu-background-color);
background-color: var(--app-menu-background-color);
border: 1px solid var(--normal-border-color);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
border-radius: 8px;
@@ -82,7 +95,9 @@ export default {
.dropdown-item {
padding: 8px 16px;
white-space: nowrap;
cursor: pointer;
}
.dropdown-item:hover {
background-color: var(--menu-selected-background-color);
}

View File

@@ -8,6 +8,13 @@
/>
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
<ActivityPopup
:visible="showInviteCodePopup"
:icon="inviteCodeIcon"
text="邀请码活动开始了,速来参与大伙们🔥🔥🔥"
@close="closeInviteCodePopup"
/>
</div>
</template>
@@ -21,7 +28,10 @@ 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([])
@@ -30,6 +40,9 @@ onMounted(async () => {
await checkMilkTeaActivity()
if (showMilkTeaPopup.value) return
await checkInviteCodeActivity()
if (showInviteCodePopup.value) return
await checkNotificationSetting()
if (showNotificationPopup.value) return
@@ -53,12 +66,38 @@ 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

View File

@@ -8,7 +8,7 @@
</button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
</div>
<NuxtLink class="logo-container" :to="`/`">
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img
alt="OpenIsle"
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
@@ -20,11 +20,22 @@
</div>
<ClientOnly>
<div v-if="isLogin" class="header-content-right">
<div class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i>
</div>
<DropdownMenu ref="userMenu" :items="headerMenuItems">
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
<i :class="iconClass"></i>
</div>
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i>
</div>
</ToolTip>
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
<template #trigger>
<div class="avatar-container">
<img class="avatar-img" :src="avatar" alt="avatar" />
@@ -32,14 +43,11 @@
</div>
</template>
</DropdownMenu>
</div>
<div v-else class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i>
<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>
<div class="header-content-item-main" @click="goToLogin">登录</div>
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
</div>
</ClientOnly>
@@ -52,10 +60,13 @@
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,
@@ -113,12 +124,33 @@ 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) {
@@ -152,11 +184,11 @@ onMounted(async () => {
<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: blur(10px);
backdrop-filter: var(--blur-10);
color: var(--header-text-color);
border-bottom: 1px solid var(--header-border-color);
}
@@ -175,11 +207,10 @@ 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 {
@@ -189,6 +220,14 @@ 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;
@@ -270,7 +309,13 @@ onMounted(async () => {
background-color: var(--menu-selected-background-color);
}
.search-icon {
.search-icon,
.theme-icon {
font-size: 18px;
cursor: pointer;
}
.new-post-icon {
font-size: 18px;
cursor: pointer;
}

View File

@@ -0,0 +1,186 @@
<template>
<div class="invite-code-activity">
<div class="invite-code-description">
<div class="invite-code-description-title">
<i class="fas fa-info-circle"></i>
<span class="invite-code-description-title-text">邀请规则说明</span>
</div>
<div class="invite-code-description-content">
<p>邀请好友注册并登录每次可以获得500积分🎉🎉🎉</p>
<p>邀请链接的有效期为1个月</p>
<p>每一个邀请链接的邀请人数上限为3人</p>
<p>通过邀请链接注册无需注册审核</p>
<p>每人每天仅能生产1个邀请链接</p>
</div>
</div>
<div v-if="inviteLink" class="invite-code-link-content">
<p>
邀请链接{{ inviteLink }}
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
</p>
</div>
<div :class="['generate-button', { disabled: !user || loadingInvite }]" @click="generateInvite">
生成邀请链接
</div>
</div>
</template>
<script setup>
import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const user = ref(null)
const isLoadingUser = ref(true)
const inviteCode = ref('')
const loadingInvite = ref(false)
const inviteLink = computed(() =>
inviteCode.value ? `${WEBSITE_BASE_URL}/signup?invite_token=${inviteCode.value}` : '',
)
onMounted(async () => {
isLoadingUser.value = true
user.value = await fetchCurrentUser()
isLoadingUser.value = false
// if (user.value) {
// await fetchInvite(false)
// }
})
const fetchInvite = async (showToast = true) => {
loadingInvite.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
loadingInvite.value = false
return
}
try {
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
inviteCode.value = data.token
if (showToast) toast.success('邀请链接已生成')
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || '生成邀请链接失败')
}
} catch (e) {
toast.error('生成邀请链接失败')
} finally {
loadingInvite.value = false
}
}
const generateInvite = () => fetchInvite(true)
const copyLink = async () => {
if (!inviteLink.value) return
try {
await navigator.clipboard.writeText(inviteLink.value)
toast.success('已复制')
} catch (e) {
toast.error('复制失败')
}
}
</script>
<style scoped>
.invite-code-description-title-text {
font-size: 14px;
font-weight: bold;
margin-left: 5px;
}
.invite-code-description-content {
font-size: 12px;
opacity: 0.8;
}
.status-title {
font-weight: bold;
}
.status-text {
font-size: 12px;
opacity: 0.8;
}
.invite-code-activity {
margin-top: 20px;
padding: 20px;
}
.generate-button {
margin-top: 20px;
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 10px;
width: fit-content;
cursor: pointer;
}
.generate-button:hover {
background-color: var(--primary-color-hover);
}
.generate-button.disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.generate-button.disabled:hover {
background-color: var(--primary-color-disabled);
}
.invite-code-status-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 30px;
margin-top: 20px;
}
.invite-code-status {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
font-size: 14px;
}
.user-level-text {
opacity: 0.8;
font-size: 12px;
color: var(--primary-color);
}
.invite-code-link-content {
margin-top: 20px;
font-size: 12px;
opacity: 0.8;
}
.copy-icon {
cursor: pointer;
margin-left: 5px;
}
@media screen and (max-width: 768px) {
.invite-code-status-container {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>

View File

@@ -35,7 +35,7 @@ const goLogin = () => {
left: 0;
right: 0;
bottom: 0;
backdrop-filter: blur(4px);
backdrop-filter: var(--blur-4);
z-index: 1;
}

View File

@@ -1,168 +1,195 @@
<template>
<transition name="slide">
<nav v-if="visible" class="menu">
<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)"
<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"
>
<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>
<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>
</div>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/about"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-info-circle"></i>
<span class="menu-item-text">关于</span>
</NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/activities"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-gift"></i>
<span class="menu-item-text">🔥 活动</span>
</NuxtLink>
<NuxtLink
v-if="shouldShowStats"
class="menu-item"
exact-active-class="selected"
to="/about/stats"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-chart-line"></i>
<span class="menu-item-text">站点统计</span>
</NuxtLink>
<NuxtLink
v-if="authState.loggedIn"
class="menu-item"
exact-active-class="selected"
to="/points"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-coins"></i>
<span class="menu-item-text">
积分商城
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
</span>
</NuxtLink>
</div>
</div>
<div 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 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-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 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>
<div class="menu-section">
<div class="section-header" @click="tagOpen = !tagOpen">
<span>tag</span>
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
<div v-if="tagOpen" class="section-items">
<div v-if="isLoadingTag" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
<img
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
:alt="t.name"
/>
<i v-else class="section-item-icon fas fa-hashtag"></i>
<span class="section-item-text"
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
>
</div>
</div>
</div>
</div>
<div class="menu-footer">
<div class="menu-footer-btn" @click="cycleTheme">
<i :class="iconClass"></i>
<!-- 解决动态样式的水合错误 -->
<ClientOnly v-if="!isMobile">
<div class="menu-footer">
<div class="menu-footer-btn" @click="cycleTheme">
<i :class="iconClass"></i>
</div>
</div>
</div>
</ClientOnly>
</nav>
</transition>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { authState } from '~/utils/auth'
import { computed, onMounted, ref, watch } from 'vue'
import { authState, fetchCurrentUser } 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 isLoadingCategory = ref(false)
const isLoadingTag = ref(false)
const categoryData = ref([])
const tagData = ref([])
const myPoint = ref(null)
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
}
/** ✅ 用 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 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 {
data: tagData,
pending: isLoadingTag,
error: tagError,
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
server: true,
default: () => [],
staleTime: 5 * 60 * 1000,
})
/** 其余逻辑保持不变 */
const iconClass = computed(() => {
switch (themeState.mode) {
case ThemeMode.DARK:
@@ -178,6 +205,15 @@ 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()
@@ -187,8 +223,15 @@ const updateCount = async () => {
}
onMounted(async () => {
await updateCount()
watch(() => authState.loggedIn, updateCount)
await Promise.all([updateCount(), loadPoint()])
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
watch(
() => authState.loggedIn,
() => {
updateCount()
loadPoint()
},
)
})
const handleItemClick = () => {
@@ -211,28 +254,35 @@ 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: 200px;
background-color: var(--menu-background-color);
width: 220px;
background-color: var(--app-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-item-container {
.menu-content {
width: 100%;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
padding: 10px 10px 0 10px;
}
/* .menu-item-container { */
/**/
/* } */
.menu-item {
padding: 4px 10px;
text-decoration: none;
@@ -271,6 +321,12 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
font-weight: bold;
}
.point-count {
margin-left: 4px;
font-size: 12px;
color: var(--primary-color);
}
.menu-item-icon {
margin-right: 10px;
opacity: 0.5;
@@ -278,10 +334,8 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
}
.menu-footer {
position: fixed;
position: relation;
height: 30px;
bottom: 10px;
right: 10px;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -369,6 +423,10 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
background-color: var(--background-color-blur);
}
.menu-content {
border-radius: 20px;
}
.slide-enter-active,
.slide-leave-active {
transition:

View File

@@ -40,30 +40,22 @@
兑换
</div>
<div v-else class="redeem-button disabled">兑换</div>
<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>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeDialog"
@submit="submitRedeem"
/>
</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
@@ -185,56 +177,6 @@ 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;
@@ -247,9 +189,5 @@ const submitRedeem = async () => {
align-items: flex-start;
gap: 10px;
}
.redeem-dialog-content {
min-width: 300px;
}
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<BasePopup :visible="visible" @close="onClose">
<div class="redeem-dialog-content">
<BaseInput
textarea
rows="5"
v-model="innerContact"
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
/>
<div class="redeem-actions">
<div class="redeem-submit-button" @click="submit" :disabled="loading">提交</div>
<div class="redeem-cancel-button" @click="onClose">取消</div>
</div>
</div>
</BasePopup>
</template>
<script setup>
import { ref, watch } from 'vue'
import BaseInput from '~/components/BaseInput.vue'
import BasePopup from '~/components/BasePopup.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'submit', 'close'])
const innerContact = ref(props.modelValue)
watch(
() => props.modelValue,
(v) => {
innerContact.value = v
},
)
watch(innerContact, (v) => emit('update:modelValue', v))
const submit = () => {
emit('submit')
}
const onClose = () => {
emit('close')
}
</script>
<style scoped>
.redeem-dialog-content {
position: relative;
z-index: 2;
background-color: var(--background-color);
display: flex;
flex-direction: column;
gap: 10px;
min-width: 400px;
}
.redeem-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 20px;
align-items: center;
}
.redeem-submit-button {
background-color: var(--primary-color);
color: #fff;
padding: 10px 20px;
border-radius: 10px;
cursor: pointer;
}
.redeem-submit-button:disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.redeem-submit-button:hover {
background-color: var(--primary-color-hover);
}
.redeem-submit-button:disabled:hover {
background-color: var(--primary-color-disabled);
}
.redeem-cancel-button {
color: var(--primary-color);
border-radius: 10px;
cursor: pointer;
}
.redeem-cancel-button:hover {
text-decoration: underline;
}
@media screen and (max-width: 768px) {
.redeem-dialog-content {
min-width: 300px;
}
}
</style>

View File

@@ -37,10 +37,10 @@
</template>
<script setup>
import { useIsMobile } from '~/utils/screen'
import { ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
import { stripMarkdown } from '~/utils/markdown'
import { ref, watch } from 'vue'
import { useIsMobile } from '~/utils/screen'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -110,6 +110,10 @@ watch(selected, (val) => {
selected.value = null
keyword.value = ''
})
defineExpose({
toggle,
})
</script>
<style scoped>
@@ -131,7 +135,7 @@ watch(selected, (val) => {
}
.text-input {
background-color: var(--menu-background-color);
background-color: var(--app-menu-background-color);
color: var(--text-color);
border: none;
outline: none;

View File

@@ -0,0 +1,547 @@
<template>
<div class="tooltip-wrapper" ref="wrapperRef">
<!-- 触发器 -->
<div
class="tooltip-trigger"
:tabindex="focusable ? 0 : -1"
:aria-describedby="visible ? ariaId : undefined"
@mouseenter="onTriggerMouseEnter"
@mouseleave="onTriggerMouseLeave"
@click="onTriggerClick"
@focus="onTriggerFocus"
@blur="onTriggerBlur"
>
<slot />
</div>
<!-- 提示内容Teleport body -->
<Teleport to="body" v-if="mounted">
<Transition name="tooltip-fade">
<div
v-show="visible"
:id="ariaId"
ref="tooltipRef"
class="tooltip-content"
:class="[
`tooltip-${currentPlacement}`,
dark ? 'tooltip-dark' : 'tooltip-light',
props.trigger === 'hover' ? 'tooltip-noninteractive' : '',
]"
:style="tooltipInlineStyle"
role="tooltip"
>
<div class="tooltip-inner">
<slot name="content">
{{ content }}
</slot>
</div>
<!-- 箭头 -->
<div
class="tooltip-arrow"
:class="`tooltip-arrow-${currentPlacement}`"
:style="arrowStyle"
></div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import {
ref,
computed,
onMounted,
onBeforeUnmount,
nextTick,
watch,
defineProps,
defineEmits,
defineOptions,
useId,
} from 'vue'
defineOptions({ name: 'Tooltip' })
type Trigger = 'hover' | 'click' | 'focus' | 'manual'
type Placement = 'top' | 'bottom' | 'left' | 'right'
const props = defineProps({
content: { type: String, default: '' },
trigger: {
type: String as () => Trigger,
default: 'hover',
validator: (v: string) => ['hover', 'click', 'focus', 'manual'].includes(v),
},
placement: {
type: String as () => Placement,
default: 'top',
validator: (v: string) => ['top', 'bottom', 'left', 'right'].includes(v),
},
dark: { type: Boolean, default: false },
delay: { type: Number, default: 100 },
disabled: { type: Boolean, default: false },
focusable: { type: Boolean, default: true },
offset: { type: Number, default: 8 },
maxWidth: { type: [String, Number], default: '200px' },
/** 隐藏延时毫秒hover 离开后等待一点点以防抖 */
hideDelay: { type: Number, default: 80 },
})
const emit = defineEmits<{
(e: 'show'): void
(e: 'hide'): void
}>()
const wrapperRef = ref<HTMLElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
const visible = ref(false)
const currentPlacement = ref<Placement>(props.placement)
const ariaId = ref(`tooltip-${useId()}`)
const mounted = ref(false)
let showTimer: number | null = null
let hideTimer: number | null = null
let ro: ResizeObserver | null = null
let rafId: number | null = null
const maxWidthValue = computed(() => {
return typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
})
const tooltipTransform = ref('translate3d(-9999px, -9999px, 0)')
const tooltipInlineStyle = computed(() => ({
position: 'fixed',
top: '0px',
left: '0px',
zIndex: 2000,
maxWidth: maxWidthValue.value,
transform: tooltipTransform.value,
}))
const arrowStyle = ref<Record<string, string>>({})
const clearTimers = () => {
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
if (hideTimer) {
window.clearTimeout(hideTimer)
hideTimer = null
}
}
const show = async () => {
if (props.disabled) return
clearTimers()
showTimer = window.setTimeout(async () => {
visible.value = true
emit('show')
await nextTick()
updatePosition()
}, props.delay)
}
const hide = () => {
clearTimers()
hideTimer = window.setTimeout(() => {
visible.value = false
emit('hide')
}, props.hideDelay)
}
const showImmediately = async () => {
if (props.disabled) return
clearTimers()
visible.value = true
emit('show')
await nextTick()
updatePosition()
}
const hideImmediately = () => {
clearTimers()
visible.value = false
emit('hide')
}
// 触发器事件
const onTriggerMouseEnter = () => {
if (props.trigger === 'hover') show()
}
const onTriggerMouseLeave = () => {
// 关键修改hover 模式下,离开触发区即开始隐藏计时,不再保持可交互
if (props.trigger === 'hover') hide()
}
const onTriggerClick = () => {
if (props.trigger !== 'click') return
visible.value ? hideImmediately() : showImmediately()
}
const onTriggerFocus = () => {
if (props.trigger === 'focus') showImmediately()
}
const onTriggerBlur = () => {
if (props.trigger === 'focus') hideImmediately()
}
// 点击外部关闭(只对 click 模式)
const onClickOutside = (e: MouseEvent) => {
if (props.trigger !== 'click') return
const w = wrapperRef.value
const t = tooltipRef.value
const target = e.target as Node
if (w && !w.contains(target) && t && !t.contains(target)) {
hideImmediately()
}
}
// 定位算法
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function computeBasePosition(
placement: Placement,
triggerRect: DOMRect,
tooltipRect: DOMRect,
offset: number,
) {
const centerX = triggerRect.left + triggerRect.width / 2
const centerY = triggerRect.top + triggerRect.height / 2
switch (placement) {
case 'top':
return {
top: triggerRect.top - tooltipRect.height - offset,
left: centerX - tooltipRect.width / 2,
}
case 'bottom':
return {
top: triggerRect.bottom + offset,
left: centerX - tooltipRect.width / 2,
}
case 'left':
return {
top: centerY - tooltipRect.height / 2,
left: triggerRect.left - tooltipRect.width - offset,
}
case 'right':
return {
top: centerY - tooltipRect.height / 2,
left: triggerRect.right + offset,
}
}
}
function positionWithSmartFlip(
preferred: Placement,
triggerRect: DOMRect,
tooltipRect: DOMRect,
offset: number,
) {
const padding = 8
const vw = window.innerWidth
const vh = window.innerHeight
let placement: Placement = preferred
let { top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!
const outTop = top < padding
const outBottom = top + tooltipRect.height > vh - padding
const outLeft = left < padding
const outRight = left + tooltipRect.width > vw - padding
if (
placement === 'top' &&
outTop &&
triggerRect.bottom + offset + tooltipRect.height <= vh - padding
) {
placement = 'bottom'
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
} else if (
placement === 'bottom' &&
outBottom &&
triggerRect.top - offset - tooltipRect.height >= padding
) {
placement = 'top'
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
} else if (
placement === 'left' &&
outLeft &&
triggerRect.right + offset + tooltipRect.width <= vw - padding
) {
placement = 'right'
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
} else if (
placement === 'right' &&
outRight &&
triggerRect.left - offset - tooltipRect.width >= padding
) {
placement = 'left'
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
}
top = clamp(top, padding, vh - tooltipRect.height - padding)
left = clamp(left, padding, vw - tooltipRect.width - padding)
const triggerCenterX = triggerRect.left + triggerRect.width / 2
const triggerCenterY = triggerRect.top + triggerRect.height / 2
const arrowLeft = clamp(triggerCenterX - left, 10, tooltipRect.width - 10)
const arrowTop = clamp(triggerCenterY - top, 10, tooltipRect.height - 10)
return { placement, top, left, arrowLeft, arrowTop }
}
const updatePosition = () => {
if (!wrapperRef.value || !tooltipRef.value || !visible.value) return
if (rafId) cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(() => {
const triggerEl = wrapperRef.value!.querySelector('.tooltip-trigger') as HTMLElement | null
const tooltipEl = tooltipRef.value!
if (!triggerEl) return
const triggerRect = triggerEl.getBoundingClientRect()
const tooltipRect = tooltipEl.getBoundingClientRect()
const { placement, top, left, arrowLeft, arrowTop } = positionWithSmartFlip(
props.placement,
triggerRect,
tooltipRect,
props.offset,
)
currentPlacement.value = placement
tooltipTransform.value = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`
if (placement === 'top' || placement === 'bottom') {
arrowStyle.value = { '--arrow-left': `${Math.round(arrowLeft)}px` } as any
} else {
arrowStyle.value = { '--arrow-top': `${Math.round(arrowTop)}px` } as any
}
})
}
const onEnvChanged = () => {
if (visible.value) updatePosition()
}
watch(
() => props.disabled,
(v) => {
if (v && visible.value) hideImmediately()
},
)
watch(
() => props.placement,
() => {
if (visible.value) nextTick(updatePosition)
},
)
watch(visible, (v) => {
if (!mounted.value) return
if (v) {
if ('ResizeObserver' in window && !ro) {
ro = new ResizeObserver(() => updatePosition())
if (tooltipRef.value) ro.observe(tooltipRef.value)
const triggerEl = wrapperRef.value?.querySelector('.tooltip-trigger') as HTMLElement | null
if (triggerEl) ro.observe(triggerEl)
}
updatePosition()
} else {
if (ro) {
ro.disconnect()
ro = null
}
}
})
onMounted(() => {
mounted.value = true
window.addEventListener('resize', onEnvChanged, { passive: true })
window.addEventListener('scroll', onEnvChanged, { passive: true, capture: true })
document.addEventListener('click', onClickOutside, true)
})
onBeforeUnmount(() => {
clearTimers()
if (rafId) cancelAnimationFrame(rafId)
if (ro) {
ro.disconnect()
ro = null
}
document.removeEventListener('click', onClickOutside, true)
window.removeEventListener('resize', onEnvChanged)
window.removeEventListener('scroll', onEnvChanged, true)
})
// 暴露给父组件manual 可用)
defineExpose({ show: showImmediately, hide: hideImmediately, updatePosition })
</script>
<style scoped>
.tooltip-wrapper {
position: relative;
display: inline-block;
}
.tooltip-trigger {
display: inline-block;
outline: none;
}
.tooltip-trigger:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: 4px;
}
.tooltip-content {
will-change: transform;
pointer-events: auto; /* 默认允许交互click/focus 模式) */
}
.tooltip-noninteractive {
/* hover 模式下禁用指针事件,避免移入浮层导致保持显示 */
pointer-events: none;
}
.tooltip-inner {
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
word-wrap: break-word;
border: 1px solid transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* 主题 */
.tooltip-light .tooltip-inner {
background-color: var(--background-color);
color: var(--text-color);
border-color: var(--normal-border-color);
}
.tooltip-dark .tooltip-inner {
background-color: rgba(0, 0, 0, 0.9);
color: #fff;
}
/* 箭头(用 CSS 变量控制偏移) */
.tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
/* 顶部 */
.tooltip-top .tooltip-arrow-top {
bottom: -6px;
left: var(--arrow-left, 50%);
transform: translateX(-50%);
border-width: 6px 6px 0 6px;
}
.tooltip-light.tooltip-top .tooltip-arrow-top {
border-color: var(--normal-border-color) transparent transparent transparent;
}
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
content: '';
position: absolute;
top: -7px;
left: -6px;
border-width: 6px 6px 0 6px;
border-style: solid;
border-color: var(--background-color) transparent transparent transparent;
}
.tooltip-dark.tooltip-top .tooltip-arrow-top {
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
}
/* 底部 */
.tooltip-bottom .tooltip-arrow-bottom {
top: -6px;
left: var(--arrow-left, 50%);
transform: translateX(-50%);
border-width: 0 6px 6px 6px;
}
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
border-color: transparent transparent var(--normal-border-color) transparent;
}
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
content: '';
position: absolute;
top: 1px;
left: -6px;
border-width: 0 6px 6px 6px;
border-style: solid;
border-color: transparent transparent var(--background-color) transparent;
}
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
}
/* 左侧 */
.tooltip-left .tooltip-arrow-left {
right: -6px;
top: var(--arrow-top, 50%);
transform: translateY(-50%);
border-width: 6px 0 6px 6px;
}
.tooltip-light.tooltip-left .tooltip-arrow-left {
border-color: transparent transparent transparent var(--normal-border-color);
}
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
content: '';
position: absolute;
top: -6px;
left: -7px;
border-width: 6px 0 6px 6px;
border-style: solid;
border-color: transparent transparent transparent var(--background-color);
}
.tooltip-dark.tooltip-left .tooltip-arrow-left {
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
}
/* 右侧 */
.tooltip-right .tooltip-arrow-right {
left: -6px;
top: var(--arrow-top, 50%);
transform: translateY(-50%);
border-width: 6px 6px 6px 0;
}
.tooltip-light.tooltip-right .tooltip-arrow-right {
border-color: transparent var(--normal-border-color) transparent transparent;
}
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
content: '';
position: absolute;
top: -6px;
left: 1px;
border-width: 6px 6px 6px 0;
border-style: solid;
border-color: transparent var(--background-color) transparent transparent;
}
.tooltip-dark.tooltip-right .tooltip-arrow-right {
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
}
/* 过渡动画 */
.tooltip-fade-enter-active,
.tooltip-fade-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.tooltip-fade-enter-from,
.tooltip-fade-leave-to {
opacity: 0;
transform: translate3d(0, 4px, 0) scale(0.98);
}
/* 响应式微调 */
@media (max-width: 768px) {
.tooltip-inner {
padding: 6px 10px;
font-size: 13px;
max-width: 250px;
}
}
</style>

View File

@@ -1 +1,5 @@
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

View File

@@ -15,6 +15,7 @@ export default defineNuxtConfig({
// 确保 Vditor 样式在 global.css 覆盖前加载
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
app: {
pageTransition: { name: 'page', mode: 'out-in' },
head: {
script: [
{
@@ -26,7 +27,31 @@ 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;
} catch (e) {}
let themeColor = '#fff';
let themeStatus = 'default';
if (theme === 'dark') {
themeColor = '#333';
themeStatus = 'black-translucent';
} else {
themeColor = '#ffffff';
themeStatus = 'default';
}
const androidMeta = document.createElement('meta');
androidMeta.name = 'theme-color';
androidMeta.content = themeColor;
const iosMeta = document.createElement('meta');
iosMeta.name = 'apple-mobile-web-app-status-bar-style';
iosMeta.content = themeStatus;
document.head.appendChild(androidMeta);
document.head.appendChild(iosMeta);
} catch (e) {
console.warn('Theme initialization failed:', e);
}
})();
`,
},
@@ -60,4 +85,27 @@ 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'
}
}
},
},
},
},
},
})

View File

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
</div>
</div>
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
<InviteCodeActivityComponent v-if="a.type === 'INVITE_POINTS'" />
</div>
</div>
</template>
@@ -32,6 +33,7 @@
<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
@@ -75,6 +77,7 @@ 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 {
@@ -141,6 +144,10 @@ onMounted(async () => {
color: inherit;
}
.activity-card-normal-right {
width: 100%;
}
@media screen and (max-width: 768px) {
.activity-card-left-avatar-img {
width: 80px;

View File

@@ -1,3 +1,4 @@
<!-- pages/discord-callback.vue -->
<template>
<CallbackPage />
</template>
@@ -8,9 +9,30 @@ import { discordExchange } from '~/utils/discord'
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await discordExchange(code, state, '')
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, '')
if (result.needReason) {
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })

View File

@@ -2,6 +2,7 @@
<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>
@@ -19,6 +20,10 @@
<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>
@@ -26,6 +31,8 @@
<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
@@ -39,6 +46,7 @@ const passwordError = ref('')
const isSending = ref(false)
const isVerifying = ref(false)
const isResetting = ref(false)
const route = useRoute()
onMounted(() => {
if (route.query.email) {
@@ -137,6 +145,21 @@ 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;

View File

@@ -1,3 +1,4 @@
<!-- pages/github-callback.vue -->
<template>
<CallbackPage />
</template>
@@ -8,9 +9,31 @@ 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')
const result = await githubExchange(code, state, '')
const code = url.searchParams.get('code') || ''
const state = url.searchParams.get('state') || ''
// 从 state 中解析 invite_tokengithubAuthorize 已把它放进 state
let inviteToken = ''
if (state) {
try {
const s = new URLSearchParams(state)
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
} catch {}
}
// 兜底也支持直接跟在回调URL的查询参数上
// if (!inviteToken) {
// inviteToken =
// url.searchParams.get('invite_token') ||
// url.searchParams.get('invitetoken') ||
// ''
// }
if (!code) {
navigateTo('/login', { replace: true })
return
}
const result = await githubExchange(code, inviteToken, '')
if (result.needReason) {
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })

View File

@@ -9,6 +9,21 @@ 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,
@@ -18,6 +33,7 @@ onMounted(async () => {
(token) => {
navigateTo(`/signup-reason?token=${token}`, { replace: true })
},
{ inviteToken },
)
} else {
navigateTo('/login', { replace: true })

View File

@@ -1,10 +1,7 @@
<template>
<div class="home-page">
<div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航</div>
<div class="search-subtitle">
愿你在此遇见灵感与共鸣若有疑惑欢迎发问亦可在知识的海洋中搜寻答案
</div>
<div class="search-title">一切可能从此刻启航在此遇见灵感与共鸣</div>
<SearchDropdown />
</div>
@@ -116,7 +113,7 @@
</div>
</template>
<script setup>
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
@@ -150,9 +147,17 @@ const categoryOptions = ref([])
const isLoadingMore = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopicCookie = useCookie('homeTab')
const selectedTopic = ref(
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
selectedTopicCookie.value
? selectedTopicCookie.value
: route.query.view === 'ranking'
? '排行榜'
: route.query.view === 'latest'
? '最新'
: '最新回复',
)
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
const articles = ref([])
const page = ref(0)
const pageSize = 10
@@ -178,6 +183,11 @@ onMounted(() => {
const { category, tags } = route.query
if (category) selectedCategorySet(category)
if (tags) selectedTagsSet(tags)
const saved = localStorage.getItem('homeTab')
if (saved) {
selectedTopic.value = saved
}
})
/** 路由变更时同步筛选 **/
@@ -195,7 +205,7 @@ watch(
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
const res = await fetch(`${API_BASE_URL}/api/categories/`)
if (res.ok) categoryOptions.value = [await res.json()]
} catch {}
}
@@ -343,9 +353,13 @@ watch(
watch([selectedCategory, selectedTags], () => {
loadOptions()
})
watch(selectedTopic, () => {
watch(selectedTopic, (val) => {
// 仅当需要额外选项时加载
loadOptions()
selectedTopicCookie.value = val
if (process.client) {
localStorage.setItem('homeTab', val)
}
})
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
@@ -354,6 +368,8 @@ if (import.meta.server) {
}
onMounted(() => {
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
window.addEventListener('refresh-home', refreshFirst)
})
/** 其他工具函数 **/
@@ -371,8 +387,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
}
.search-container {
margin-top: 100px;
padding: 20px;
margin-top: 32px;
padding: 20px 20px 32px;
display: flex;
flex-direction: column;
align-items: center;
@@ -384,10 +400,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
font-weight: bold;
}
.search-subtitle {
font-size: 16px;
}
.loading-container {
display: flex;
justify-content: center;
@@ -422,6 +434,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
gap: 10px;
width: 100%;
padding: 10px 0;
backdrop-filter: var(--blur-10);
}
.topic-item-container {
@@ -541,6 +554,7 @@ 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;

View File

@@ -27,6 +27,10 @@
>找回密码</a
>
</div>
<div class="hint-message">
<i class="fas fa-info-circle"></i>
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
</div>
</div>
</div>
@@ -259,6 +263,11 @@ const loginWithTwitter = () => {
color: var(--primary-color);
}
.hint-message {
font-size: 12px;
opacity: 0.7;
}
@media (max-width: 768px) {
.login-page {
flex-direction: column;

View File

@@ -130,6 +130,12 @@
申请进行奶茶兑换联系方式是{{ 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> 对我的文章
@@ -505,21 +511,26 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import { computed, onMounted, ref } from 'vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.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',
)
@@ -528,234 +539,6 @@ 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()
}
@@ -833,6 +616,8 @@ const formatType = (t) => {
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
case 'POINT_REDEEM':
return '有人申请积分兑换'
case 'LOTTERY_WIN':
return '抽奖中奖了'
case 'LOTTERY_DRAW':
@@ -842,7 +627,7 @@ const formatType = (t) => {
}
}
onMounted(() => {
onActivated(() => {
fetchNotifications()
fetchPrefs()
})
@@ -859,6 +644,8 @@ onMounted(() => {
.message-page {
background-color: var(--background-color);
overflow-x: hidden;
height: calc(100vh - var(--header-height));
overflow-y: auto;
}
.message-page-header {
@@ -870,6 +657,7 @@ onMounted(() => {
flex-direction: row;
justify-content: space-between;
align-items: center;
backdrop-filter: var(--blur-10);
}
.message-page-header-right {

View File

@@ -0,0 +1,230 @@
<template>
<div class="point-mall-page">
<section class="rules">
<div class="section-title">🎉 积分规则</div>
<div class="section-content">
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
</div>
</section>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span class="point-value">{{
point
}}</span>
</p>
</div>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
</div>
</div>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
/>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
import { toast } from '~/main'
import RedeemPopup from '~/components/RedeemPopup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const point = ref(null)
const isLoading = ref(false)
const pointRules = [
'发帖:每天前两次,每次 30 积分',
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
'帖子被点赞:每次 10 积分',
'评论被点赞:每次 10 积分',
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
]
const goods = ref([])
const dialogVisible = ref(false)
const contact = ref('')
const loading = ref(false)
const selectedGood = ref(null)
onMounted(async () => {
isLoading.value = true
if (authState.loggedIn) {
const user = await fetchCurrentUser()
point.value = user ? user.point : null
}
await loadGoods()
isLoading.value = false
})
const loadGoods = async () => {
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
if (res.ok) {
goods.value = await res.json()
}
}
const openRedeem = (good) => {
if (!authState.loggedIn || point.value === null || point.value < good.cost) {
toast.error('积分不足')
return
}
selectedGood.value = good
dialogVisible.value = true
}
const closeRedeem = () => {
dialogVisible.value = false
}
const submitRedeem = async () => {
if (!selectedGood.value || !contact.value) return
loading.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/point-goods/redeem`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ goodId: selectedGood.value.id, contact: contact.value }),
})
if (res.ok) {
const data = await res.json()
point.value = data.point
toast.success('兑换成功!')
dialogVisible.value = false
contact.value = ''
} else {
toast.error('兑换失败')
}
loading.value = false
}
</script>
<style scoped>
.point-mall-page {
padding-left: 20px;
max-width: var(--page-max-width);
background-color: var(--background-color);
margin: 0 auto;
}
.loading-points-container {
margin-top: 100px;
display: flex;
justify-content: center;
align-items: center;
}
.point-info {
font-size: 18px;
}
.point-value {
font-weight: bold;
color: var(--primary-color);
}
.coin-icon {
margin-right: 5px;
}
.rules,
.goods {
margin-top: 20px;
}
.goods {
display: flex;
gap: 10px;
}
.goods-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
overflow: hidden;
}
.goods-item-name {
font-size: 20px;
font-weight: bold;
}
.goods-item-image {
width: 200px;
height: 200px;
border-bottom: 1px solid var(--normal-border-color);
}
.goods-item-cost {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
opacity: 0.7;
}
.goods-item-button {
background-color: var(--primary-color);
color: white;
padding: 7px 10px;
border-radius: 10px;
width: calc(100% - 40px);
text-align: center;
cursor: pointer;
margin-bottom: 10px;
}
.goods-item-button:hover {
background-color: var(--primary-color-hover);
}
.goods-item-button.disabled,
.goods-item-button.disabled:hover {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.section-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.section-content {
display: flex;
flex-direction: column;
font-size: 14px;
opacity: 0.7;
}
</style>

View File

@@ -232,7 +232,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router'
import CommentItem from '~/components/CommentItem.vue'
@@ -268,7 +268,6 @@ 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([])
@@ -455,38 +454,41 @@ const onCommentDeleted = (id) => {
fetchComments()
}
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 {
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 totalPosts = computed(() => comments.value.length + 1)
const lastReplyTime = computed(() =>
@@ -607,6 +609,7 @@ const approvePost = async () => {
if (res.ok) {
status.value = 'PUBLISHED'
toast.success('已通过审核')
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -620,8 +623,8 @@ const pinPost = async () => {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
pinnedAt.value = new Date().toISOString()
toast.success('已置顶')
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -635,8 +638,8 @@ const unpinPost = async () => {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
pinnedAt.value = null
toast.success('已取消置顶')
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -674,6 +677,7 @@ const rejectPost = async () => {
if (res.ok) {
status.value = 'REJECTED'
toast.success('已驳回')
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -709,7 +713,7 @@ const joinLottery = async () => {
})
if (res.ok) {
toast.success('已参与抽奖')
await fetchPost()
await refreshPost()
} else {
toast.error('操作失败')
}
@@ -780,9 +784,8 @@ onMounted(async () => {
window.addEventListener('scroll', updateCurrentIndex)
jumpToHashComment()
})
await fetchPost()
</script>
<style>
.post-page-container {
background-color: var(--background-color);
@@ -864,6 +867,7 @@ await fetchPost()
direction: ltr;
height: 300px;
width: 2px;
appearance: none;
-webkit-appearance: none;
background: transparent;
}

View File

@@ -36,6 +36,13 @@
<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>
@@ -65,12 +72,13 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } 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('')
@@ -87,6 +95,7 @@ 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
@@ -105,6 +114,7 @@ onMounted(async () => {
navigateTo('/login', { replace: true })
}
isLoadingPage.value = false
frosted.value = frostedState.enabled
})
const onAvatarChange = (e) => {
@@ -118,6 +128,7 @@ const onAvatarChange = (e) => {
reader.readAsDataURL(file)
}
}
watch(frosted, (val) => setFrosted(val))
const onCropped = ({ file, url }) => {
avatarFile.value = file
avatar.value = url
@@ -300,6 +311,58 @@ 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;
}

View File

@@ -28,6 +28,7 @@ const reason = ref('')
const error = ref('')
const isWaitingForRegister = ref(false)
const token = ref('')
const route = useRoute()
onMounted(async () => {
token.value = route.query.token || ''
@@ -50,8 +51,8 @@ const submit = async () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: this.token,
reason: this.reason,
token: token.value,
reason: reason.value,
}),
})
isWaitingForRegister.value = false

View File

@@ -69,7 +69,7 @@
</div>
<div class="other-signup-page-content">
<div class="signup-page-button" @click="googleAuthorize">
<div class="signup-page-button" @click="signupWithGoogle">
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
<div class="signup-page-button-text">Google 注册</div>
</div>
@@ -96,6 +96,9 @@ 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)
@@ -109,9 +112,11 @@ 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) {
@@ -156,6 +161,7 @@ const sendVerification = async () => {
username: username.value,
email: email.value,
password: password.value,
inviteToken: inviteToken.value,
}),
})
isWaitingForEmailSent.value = false
@@ -188,11 +194,18 @@ const verifyCode = async () => {
})
const data = await res.json()
if (res.ok) {
if (registerMode.value === 'WHITELIST') {
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
} else {
toast.success('注册成功,请登录')
navigateTo('/login', { replace: true })
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 })
}
}
} else {
toast.error(data.error || '注册失败')
@@ -203,14 +216,17 @@ const verifyCode = async () => {
isWaitingForEmailVerified.value = false
}
}
const signupWithGoogle = () => {
googleAuthorize(inviteToken.value)
}
const signupWithGithub = () => {
githubAuthorize()
githubAuthorize(inviteToken.value)
}
const signupWithDiscord = () => {
discordAuthorize()
discordAuthorize(inviteToken.value)
}
const signupWithTwitter = () => {
twitterAuthorize()
twitterAuthorize(inviteToken.value)
}
</script>

View File

@@ -35,10 +35,12 @@
/>
<div class="profile-level-target">
目标 Lv.{{ levelInfo.currentLevel + 1 }}
<i
class="fas fa-info-circle profile-exp-info"
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
></i>
<ToolTip
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
placement="bottom"
>
<i class="fas fa-info-circle profile-exp-info"></i>
</ToolTip>
</div>
</div>
</div>
@@ -204,67 +206,89 @@
</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="timelineItems.length === 0"
v-if="filteredTimelineItems.length === 0"
text="暂无时间线"
icon="fas fa-inbox"
/>
<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>
<div class="timeline-list">
<BaseTimeline :items="filteredTimelineItems">
<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>
<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>
<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>
</BaseTimeline>
</div>
</div>
<div v-else-if="selectedTab === 'following'" class="follow-container">
@@ -324,6 +348,15 @@ 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([])
@@ -654,12 +687,6 @@ 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;
@@ -700,6 +727,7 @@ 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 {
@@ -777,8 +805,24 @@ watch(selectedTab, async (val) => {
width: 40%;
}
.profile-timeline {
padding: 20px;
.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);
}
.timeline-date {

View File

@@ -0,0 +1,6 @@
import { defineNuxtPlugin } from 'nuxt/app'
import { initFrosted } from '~/utils/frosted'
export default defineNuxtPlugin(() => {
initFrosted()
})

View File

@@ -2,7 +2,7 @@ import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
export function discordAuthorize(state = '') {
export function discordAuthorize(inviteToken = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const DISCORD_CLIENT_ID = config.public.discordClientId
@@ -10,62 +10,60 @@ export function discordAuthorize(state = '') {
toast.error('Discord 登录不可用')
return
}
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}`
// 用 state 明文携带 invite_token仅用于回传不再透传给后端
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
const url =
`https://discord.com/api/oauth2/authorize` +
`?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&response_type=code` +
`&scope=${encodeURIComponent('identify email')}` +
`&state=${encodeURIComponent(state)}`
window.location.href = url
}
export async function discordExchange(code, state, reason) {
export async function discordExchange(code, inviteToken = '', 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' },
body: JSON.stringify({
code,
redirectUri: `${window.location.origin}/discord-callback`,
reason,
state,
}),
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
return {
success: true,
needReason: false,
}
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: '登录失败' }
}
}

View File

@@ -0,0 +1,26 @@
import { reactive } from 'vue'
const FROSTED_KEY = 'frosted-glass'
export const frostedState = reactive({
enabled: true,
})
function apply() {
if (!import.meta.client) return
document.documentElement.dataset.frosted = frostedState.enabled ? 'on' : 'off'
}
export function initFrosted() {
if (!import.meta.client) return
const saved = localStorage.getItem(FROSTED_KEY)
frostedState.enabled = saved !== 'false'
apply()
}
export function setFrosted(enabled) {
if (!import.meta.client) return
frostedState.enabled = enabled
localStorage.setItem(FROSTED_KEY, enabled ? 'true' : 'false')
apply()
}

View File

@@ -2,7 +2,7 @@ import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
export function githubAuthorize(state = '') {
export function githubAuthorize(inviteToken = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const GITHUB_CLIENT_ID = config.public.githubClientId
@@ -10,62 +10,58 @@ export function githubAuthorize(state = '') {
toast.error('GitHub 登录不可用')
return
}
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
const url =
`https://github.com/login/oauth/authorize` +
`?client_id=${encodeURIComponent(GITHUB_CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent('user:email')}` +
`&state=${encodeURIComponent(state)}`
window.location.href = url
}
export async function githubExchange(code, state, reason) {
export async function githubExchange(code, inviteToken = '', 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({
code,
redirectUri: `${window.location.origin}/github-callback`,
reason,
state,
}),
body: JSON.stringify(payload),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
return {
success: true,
needReason: false,
}
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: '登录失败' }
}
}

View File

@@ -21,43 +21,85 @@ export async function googleGetIdToken() {
})
}
export function googleAuthorize() {
export function googleAuthorize(inviteToken = '') {
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).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}`
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)}`
window.location.href = url
}
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
export async function googleAuthWithToken(
idToken,
redirect_success,
redirect_not_approved,
options = {}, // { inviteToken?: string }
) {
try {
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' },
body: JSON.stringify({ idToken }),
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (res.ok && data.token) {
const data = await res.json().catch(() => ({}))
if (res.ok && data && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
if (redirect_success) redirect_success()
} else if (data.reason_code === 'NOT_APPROVED') {
toast.info('当前为注册审核模式,请填写注册理由')
if (redirect_not_approved) redirect_not_approved(data.token)
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册理由正在审批中')
if (redirect_success) redirect_success()
registerPush?.()
if (typeof redirect_success === 'function') redirect_success()
return
}
if (data && data.reason_code === 'NOT_APPROVED') {
toast.info('当前为注册审核模式,请填写注册理由')
if (typeof redirect_not_approved === 'function') redirect_not_approved(data.token)
return
}
if (data && data.reason_code === 'IS_APPROVING') {
toast.info('您的注册理由正在审批中')
if (typeof redirect_success === 'function') redirect_success()
return
}
toast.error(data?.message || '登录失败')
} catch (e) {
console.error(e)
toast.error('登录失败')
}
}

View File

@@ -1,10 +1,33 @@
import { getToken } from './auth'
import { reactive } from 'vue'
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'
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
@@ -87,3 +110,235 @@ 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()

View File

@@ -14,19 +14,46 @@ export const themeState = reactive({
})
function apply(mode) {
if (!process.client) return
if (!import.meta.client) return
const root = document.documentElement
if (mode === ThemeMode.SYSTEM) {
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
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
} else {
root.dataset.theme = mode
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)
}
}
export function initTheme() {
if (!process.client) return
if (!import.meta.client) return
const saved = localStorage.getItem(THEME_KEY)
if (saved && Object.values(ThemeMode).includes(saved)) {
themeState.mode = saved
@@ -35,15 +62,117 @@ export function initTheme() {
}
export function setTheme(mode) {
if (!process.client) return
if (!import.meta.client) return
if (!Object.values(ThemeMode).includes(mode)) return
themeState.mode = mode
localStorage.setItem(THEME_KEY, mode)
apply(mode)
}
export function cycleTheme() {
if (!process.client) return
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
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
const index = modes.indexOf(themeState.mode)
const next = modes[(index + 1) % modes.length]
@@ -54,10 +183,31 @@ export function cycleTheme() {
} else {
toast.success('🌙 已经切换到暗色主题')
}
setTheme(next)
// 获取当前真实主题
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,
)
}
if (process.client && window.matchMedia) {
if (import.meta.client && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (themeState.mode === ThemeMode.SYSTEM) {
apply(ThemeMode.SYSTEM)

View File

@@ -20,7 +20,8 @@ async function generateCodeChallenge(codeVerifier) {
.replace(/=+$/, '')
}
export async function twitterAuthorize(state = '') {
// 邀请码明文放入 state同时生成 csrf 放入 state 并在回调校验
export async function twitterAuthorize(inviteToken = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const TWITTER_CLIENT_ID = config.public.twitterClientId
@@ -28,17 +29,30 @@ export async function twitterAuthorize(state = '') {
toast.error('Twitter 登录不可用')
return
}
if (state === '') {
state = Math.random().toString(36).substring(2, 15)
}
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
// PKCE
const codeVerifier = generateCodeVerifier()
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
const codeChallenge = await generateCodeChallenge(codeVerifier)
// CSRF + 邀请码一起放入 state
const csrf = Math.random().toString(36).slice(2)
sessionStorage.setItem('twitter_csrf_state', csrf)
const state = new URLSearchParams({
csrf,
invite_token: inviteToken || '',
}).toString()
const url =
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` +
`&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(TWITTER_CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent('tweet.read users.read')}` +
`&state=${encodeURIComponent(state)}` +
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
`&code_challenge_method=S256`
window.location.href = url
}
@@ -46,8 +60,29 @@ export async function twitterExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
// 取出并清理 PKCE/CSRF
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
sessionStorage.removeItem('twitter_code_verifier')
const savedCsrf = sessionStorage.getItem('twitter_csrf_state')
sessionStorage.removeItem('twitter_csrf_state')
// 从 state 解析 csrf 与 invite_token
let parsedCsrf = ''
let inviteToken = ''
try {
const sp = new URLSearchParams(state || '')
parsedCsrf = sp.get('csrf') || ''
inviteToken = sp.get('invite_token') || sp.get('invitetoken') || ''
} catch {}
// 简单 CSRF 校验(存在才校验,避免误杀老会话)
if (savedCsrf && parsedCsrf && savedCsrf !== parsedCsrf) {
toast.error('登录状态校验失败,请重试')
return { success: false, needReason: false, error: 'state mismatch' }
}
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -57,8 +92,10 @@ export async function twitterExchange(code, state, reason) {
reason,
state,
codeVerifier,
inviteToken,
}),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
@@ -77,6 +114,7 @@ export async function twitterExchange(code, state, reason) {
return { success: false, needReason: false, error: data.error || '登录失败' }
}
} catch (e) {
console.error(e)
toast.error('登录失败')
return { success: false, needReason: false, error: '登录失败' }
}