Compare commits

..

157 Commits

Author SHA1 Message Date
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
Tim
84b45f785d fix: 代码风格设置 2025-08-14 19:55:53 +08:00
tim
df56d7e885 Revert "optimize(backend): optimize /api/posts/latest-reply"
This reverts commit 1e87e9252d.
2025-08-14 18:54:12 +08:00
tim
76176e135c Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 18:27:25 +08:00
tim
ab87e0e51c fix: fix missing setup 2025-08-14 18:27:12 +08:00
Tim
5346a063bf Merge pull request #555 from netcaty/main
优化主页列表接口/api/posts/latest-reply
2025-08-14 18:19:19 +08:00
netcaty
e53f2130b8 Merge branch 'nagisa77:main' into main 2025-08-14 17:54:08 +08:00
netcat
1e87e9252d optimize(backend): optimize /api/posts/latest-reply
resolves #554
2025-08-14 17:53:01 +08:00
tim
3fc4d29dce Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 17:27:42 +08:00
tim
bcdac9d9b2 fix: delete hook update 2025-08-14 17:27:30 +08:00
Tim
ea9710d16f Merge pull request #553 from nagisa77/codex/fix-missing-comment-pinning-feature
fix: restore comment pin handling
2025-08-14 17:21:26 +08:00
Tim
47134cadc2 fix: handle pinned comments from backend 2025-08-14 17:21:08 +08:00
tim
1a1b20b9cf fix: update css import 2025-08-14 17:20:02 +08:00
Tim
b63ebb8fae Merge pull request #552 from immortal521/feat/code-block-line-number
feat: add code block line number display
2025-08-14 16:47:46 +08:00
immortal521
e0f7299a86 feat: add code block line number display
- Added Maple Mono font
- Changed code block font to Maple Mono
- Increased mobile line height from 1.1 to 1.5
2025-08-14 15:40:14 +08:00
Tim
1f9ae8d057 Merge pull request #550 from nagisa77/feature/fix_db_error
fix: fix reward db error
2025-08-14 15:21:31 +08:00
Tim
da1ad73cf6 fix: fix reward db error 2025-08-14 15:19:21 +08:00
Tim
53c603f33a Merge pull request #546 from netcaty/main
optimize(backend): batch query for /api/categories && /api/tags
2025-08-14 14:30:14 +08:00
Tim
06f86f2b21 Merge pull request #545 from nagisa77/feature/first_screen
Feature/first screen
2025-08-14 14:26:17 +08:00
Tim
22693bfdd9 fix: 首屏ssr优化 2025-08-14 14:25:38 +08:00
netcat
0058f20b1e optimize(backend): batch query for /api/categories && /api/tags 2025-08-14 14:19:04 +08:00
Tim
304d941d68 Revert "fix: use home path"
This reverts commit 2efe4e733a.
2025-08-14 13:50:58 +08:00
Tim
3dbcd2ac4d Merge pull request #543 from nagisa77/feature/first_screen
fix: use home path
2025-08-14 13:46:48 +08:00
Tim
2efe4e733a fix: use home path 2025-08-14 13:45:29 +08:00
Tim
08239a16b8 Merge pull request #542 from nagisa77/feature/first_screen
fix: 首屏ssr优化
2025-08-14 13:40:07 +08:00
Tim
cb49dc9b73 fix: 首屏ssr优化 2025-08-14 13:39:25 +08:00
Tim
43d4c9be43 Merge pull request #541 from nagisa77/feature/first_screen
fix: 首屏ssr优化
2025-08-14 13:24:17 +08:00
Tim
1dc13698ad fix: 首屏ssr优化 2025-08-14 13:22:53 +08:00
Tim
d58432dcd9 Merge pull request #540 from nagisa77/codex/fix-logo-click-triggering-window.reload 2025-08-14 12:47:43 +08:00
Tim
e7ff73c7f9 fix: prevent header logo from triggering page reload 2025-08-14 12:47:26 +08:00
Tim
4ee9532d5f Merge pull request #539 from nagisa77/codex/fix-logo-click-reload-issue 2025-08-14 12:38:11 +08:00
Tim
80c3fd8ea2 fix: prevent homepage reload on logo click 2025-08-14 12:37:54 +08:00
Tim
7e277d06d5 Merge pull request #538 from nagisa77/feature/first_screen
fix: 首屏幕ssr优化
2025-08-14 12:29:58 +08:00
Tim
d2b68119bd fix: 首屏幕ssr优化 2025-08-14 12:29:08 +08:00
Tim
f7b0d7edd5 Merge pull request #537 from nagisa77/feature/first_screen
fix: 首屏幕ssr优化
2025-08-14 11:56:26 +08:00
Tim
cdea1ab911 fix: 首屏幕ssr优化 2025-08-14 11:55:39 +08:00
Tim
ada6bfb5cf Merge pull request #536 from nagisa77/codex/add-logo-click-to-refresh-homepage
feat: refresh home when clicking header logo
2025-08-14 11:00:37 +08:00
Tim
928dbd73b5 feat: allow logo to refresh home page 2025-08-14 11:00:17 +08:00
Tim
8c1a7afc6e Merge pull request #530 from nagisa77/feature/env
fix: 前后端代码域名hardcode调整(for预发环境做准备)
2025-08-14 10:38:49 +08:00
Tim
87453f7198 fix: add .env.example 2025-08-14 10:36:02 +08:00
Tim
48e3593ef9 Merge remote-tracking branch 'origin/main' into feature/env 2025-08-14 10:34:10 +08:00
Tim
655e8f2a65 fix: setup 迁移完成 v1 2025-08-14 10:27:01 +08:00
Tim
7a0afedc7c Merge pull request #533 from CH-122/feat/link 2025-08-13 18:12:34 +08:00
Tim
902fce5174 fix: setup 迁移完成 2025-08-13 17:59:38 +08:00
Tim
0034839e8d fix: 迁移部分页面为setup 2025-08-13 17:49:51 +08:00
CH-122
148fd36fd1 Merge branch 'main' into feat/link 2025-08-13 17:48:23 +08:00
Tim
06cd663eaf Merge pull request #532 from nagisa77/codex/add-comment-pinning-feature
feat: support comment pinning
2025-08-13 16:31:12 +08:00
Tim
0edbeabac2 feat: allow post authors to pin comments 2025-08-13 16:30:48 +08:00
Tim
65cc3ee58b Merge pull request #531 from nagisa77/codex/add-post-lottery-notification-to-author 2025-08-13 16:20:09 +08:00
Tim
6965fcfb7f feat: notify lottery author 2025-08-13 16:19:53 +08:00
Tim
40520c30ec Merge pull request #529 from nagisa77/codex/refactor-to-use-environment-variables
feat: move API and OAuth IDs to runtime config
2025-08-13 16:01:07 +08:00
Tim
5d7ca3d29a feat: use runtime config for API and OAuth client IDs 2025-08-13 16:00:26 +08:00
Tim
a3aec1133b Merge pull request #528 from nagisa77/codex/add-new-prize-notification-type
feat: add lottery win notification
2025-08-13 15:58:33 +08:00
Tim
8fa715477b feat: add lottery win notification 2025-08-13 15:57:59 +08:00
CH-122
9209ebea4c feat: 添加链接插件以支持外部链接在新窗口打开 2025-08-13 15:40:40 +08:00
Tim
47a9ce5843 fix: 后端取消网址hardcode 2025-08-13 14:02:32 +08:00
Tim
dfef13e2be Merge pull request #520 from AnNingUI/main
fix: 清理掉了大部分warn,优化了在移动端侧边栏的逻辑问题
2025-08-12 21:45:46 +08:00
AnNingUI
2f4d6e68da fix: 用传递menuBtn的ref代替手动查询dom的方式 2025-08-12 21:26:24 +08:00
AnNingUI
414872f61e fix: 解决tag与类别切换需要reload整个页面的bug 2025-08-12 20:42:31 +08:00
AnNingUI
82475f71db fix: 清理掉了所有warn,优化了在移动端侧边栏的逻辑问题 2025-08-12 20:36:00 +08:00
Tim
a6874e9be3 Merge pull request #512 from nagisa77/feature/message_control
feat: message control
2025-08-12 17:45:17 +08:00
115 changed files with 8977 additions and 5783 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 name: CI & CD
on: on:
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
schedule:
- cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
jobs: jobs:
build-and-deploy: build-and-deploy:
@@ -13,22 +13,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# - uses: actions/setup-java@v4
# with:
# java-version: '17'
# distribution: 'temurin'
# - run: mvn -B clean package -DskipTests
# - uses: actions/setup-node@v4
# with:
# node-version: '20'
# - run: |
# cd open-isle-cli
# npm ci
# npm run build
- name: Deploy to Server - name: Deploy to Server
uses: appleboy/ssh-action@v1.0.3 uses: appleboy/ssh-action@v1.0.3
with: with:

View File

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

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

@@ -41,7 +41,7 @@ public class SecurityConfig {
private final UserRepository userRepository; private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler; private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService; private final UserVisitService userVisitService;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
@Bean @Bean
@@ -75,14 +75,16 @@ public class SecurityConfig {
cfg.setAllowedOrigins(List.of( cfg.setAllowedOrigins(List.of(
"http://127.0.0.1:8080", "http://127.0.0.1:8080",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1", "http://127.0.0.1",
"http://localhost:8080", "http://localhost:8080",
"http://localhost:3000", "http://localhost:3000",
"http://localhost:3001",
"http://localhost", "http://localhost",
"http://30.211.97.238:3000", "http://30.211.97.238:3000",
"http://30.211.97.238", "http://30.211.97.238",
"http://192.168.7.70", "http://192.168.7.98",
"http://192.168.7.70:8080", "http://192.168.7.98:3000",
websiteUrl, websiteUrl,
websiteUrl.replace("://www.", "://") websiteUrl.replace("://www.", "://")
)); ));

View File

@@ -0,0 +1,29 @@
package com.openisle.controller;
import com.openisle.dto.CommentDto;
import com.openisle.mapper.CommentMapper;
import com.openisle.service.CommentService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
/**
* Endpoints for administrators to manage comments.
*/
@RestController
@RequestMapping("/api/admin/comments")
@RequiredArgsConstructor
public class AdminCommentController {
private final CommentService commentService;
private final CommentMapper commentMapper;
@PostMapping("/{id}/pin")
public CommentDto pin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/{id}/unpin")
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
}

View File

@@ -18,7 +18,7 @@ public class AdminUserController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final NotificationRepository notificationRepository; private final NotificationRepository notificationRepository;
private final EmailSender emailSender; private final EmailSender emailSender;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
@PostMapping("/{id}/approve") @PostMapping("/{id}/approve")

View File

@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -44,8 +45,11 @@ public class CategoryController {
@GetMapping @GetMapping
public List<CategoryDto> list() { public List<CategoryDto> list() {
return categoryService.listCategories().stream() List<Category> all = categoryService.listCategories();
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId()))) List<Long> ids = all.stream().map(Category::getId).toList();
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
return all.stream()
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) .sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View File

@@ -85,4 +85,16 @@ public class CommentController {
commentService.deleteComment(auth.getName(), id); commentService.deleteComment(auth.getName(), id);
log.debug("deleteComment completed for comment {}", id); log.debug("deleteComment completed for comment {}", id);
} }
@PostMapping("/comments/{id}/pin")
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/comments/{id}/unpin")
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
} }

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

@@ -22,7 +22,7 @@ import java.util.List;
public class SitemapController { public class SitemapController {
private final PostRepository postRepository; private final PostRepository postRepository;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE) @GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)

View File

@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -62,8 +63,11 @@ public class TagController {
@GetMapping @GetMapping
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword, public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit) { @RequestParam(value = "limit", required = false) Integer limit) {
List<TagDto> dtos = tagService.searchTags(keyword).stream() List<Tag> tags = tagService.searchTags(keyword);
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId()))) List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
List<TagDto> dtos = tags.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) .sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList()); .collect(Collectors.toList());
if (limit != null && limit > 0 && dtos.size() > limit) { if (limit != null && limit > 0 && dtos.size() > limit) {

View File

@@ -13,6 +13,7 @@ public class CommentDto {
private Long id; private Long id;
private String content; private String content;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime pinnedAt;
private AuthorDto author; private AuthorDto author;
private List<CommentDto> replies; private List<CommentDto> replies;
private List<ReactionDto> reactions; private List<ReactionDto> reactions;

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

@@ -24,6 +24,7 @@ public class CommentMapper {
dto.setId(comment.getId()); dto.setId(comment.getId());
dto.setContent(comment.getContent()); dto.setContent(comment.getContent());
dto.setCreatedAt(comment.getCreatedAt()); dto.setCreatedAt(comment.getCreatedAt());
dto.setPinnedAt(comment.getPinnedAt());
dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor())); dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor()));
dto.setReward(0); dto.setReward(0);
return dto; return dto;

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

@@ -38,4 +38,7 @@ public class Comment {
@JoinColumn(name = "parent_id") @JoinColumn(name = "parent_id")
private Comment parent; private Comment parent;
@Column
private LocalDateTime pinnedAt;
} }

View File

@@ -22,7 +22,7 @@ public class Notification {
private Long id; private Long id;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false, length = 50)
private NotificationType type; private NotificationType type;
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)

View File

@@ -32,6 +32,10 @@ public enum NotificationType {
REGISTER_REQUEST, REGISTER_REQUEST,
/** A user redeemed an activity reward */ /** A user redeemed an activity reward */
ACTIVITY_REDEEM, ACTIVITY_REDEEM,
/** You won a lottery post */
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** You were mentioned in a post or comment */ /** You were mentioned in a post or comment */
MENTION MENTION
} }

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,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

@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
long countByCategory_Id(Long categoryId); long countByCategory_Id(Long categoryId);
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
long countDistinctByTags_Id(Long tagId); long countDistinctByTags_Id(Long tagId);
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
long countByAuthor_Id(Long userId); long countByAuthor_Id(Long userId);
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " + @Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +

View File

@@ -23,6 +23,7 @@ import java.util.List;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -129,13 +130,26 @@ public class CommentService {
Post post = postRepository.findById(postId) Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post); List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
if (sort == CommentSort.NEWEST) { java.util.List<Comment> pinned = new java.util.ArrayList<>();
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed()); java.util.List<Comment> others = new java.util.ArrayList<>();
} else if (sort == CommentSort.MOST_INTERACTIONS) { for (Comment c : list) {
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a))); if (c.getPinnedAt() != null) {
pinned.add(c);
} else {
others.add(c);
} }
log.debug("getCommentsForPost returning {} comments", list.size()); }
return list; pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
if (sort == CommentSort.NEWEST) {
others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
} else if (sort == CommentSort.MOST_INTERACTIONS) {
others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
}
java.util.List<Comment> result = new java.util.ArrayList<>();
result.addAll(pinned);
result.addAll(others);
log.debug("getCommentsForPost returning {} comments", result.size());
return result;
} }
public List<Comment> getReplies(Long parentId) { public List<Comment> getReplies(Long parentId) {
@@ -223,6 +237,32 @@ public class CommentService {
log.debug("deleteCommentCascade removed comment {}", comment.getId()); log.debug("deleteCommentCascade removed comment {}", comment.getId());
} }
@Transactional
public Comment pinComment(String username, Long id) {
Comment c = commentRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(LocalDateTime.now());
return commentRepository.save(c);
}
@Transactional
public Comment unpinComment(String username, Long id) {
Comment c = commentRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(null);
return commentRepository.save(c);
}
private int interactionCount(Comment comment) { private int interactionCount(Comment comment) {
int reactions = reactionRepository.findByComment(comment).size(); int reactions = reactionRepository.findByComment(comment).size();
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size(); int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();

View File

@@ -36,7 +36,7 @@ public class NotificationService {
private final ReactionRepository reactionRepository; private final ReactionRepository reactionRepository;
private final Executor notificationExecutor; private final Executor notificationExecutor;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]"); private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");

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.createActivityRedeemNotifications(user, good.getName() + ": " + contact);
return user.getPoint();
}
}

View File

@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List; import java.util.*;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
@@ -69,6 +68,8 @@ public class PostService {
private final EmailSender emailSender; private final EmailSender emailSender;
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>(); private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@org.springframework.beans.factory.annotation.Autowired @org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository, public PostService(PostRepository postRepository,
@@ -249,6 +250,15 @@ public class PostService {
if (w.getEmail() != null) { if (w.getEmail() != null) {
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
} }
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
if (lp.getAuthor() != null) {
if (lp.getAuthor().getEmail() != null) {
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
}
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
} }
}); });
} }
@@ -556,10 +566,31 @@ public class PostService {
return postRepository.countByCategory_Id(categoryId); return postRepository.countByCategory_Id(categoryId);
} }
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
Map<Long, Long> result = new HashMap<>();
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
dbResult.forEach(r -> {
result.put(((Long)r[0]), ((Long)r[1]));
});
return result;
}
public long countPostsByTag(Long tagId) { public long countPostsByTag(Long tagId) {
return postRepository.countDistinctByTags_Id(tagId); return postRepository.countDistinctByTags_Id(tagId);
} }
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
Map<Long, Long> result = new HashMap<>();
if (CollectionUtils.isEmpty(tagIds)) {
return result;
}
var dbResult = postRepository.countPostsByTagIds(tagIds);
dbResult.forEach(r -> {
result.put(((Long)r[0]), ((Long)r[1]));
});
return result;
}
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) { private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
return posts.stream() return posts.stream()
.sorted(java.util.Comparator .sorted(java.util.Comparator

View File

@@ -27,7 +27,7 @@ public class ReactionService {
private final NotificationService notificationService; private final NotificationService notificationService;
private final EmailSender emailSender; private final EmailSender emailSender;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
@Transactional @Transactional

View File

@@ -0,0 +1 @@
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;

View File

@@ -93,4 +93,50 @@ class PostServiceTest {
() -> service.createPost("alice", 1L, "t", "c", List.of(1L), () -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null)); null, null, null, null, null, null));
} }
@Test
void finalizeLotteryNotifiesAuthor() {
PostRepository postRepo = mock(PostRepository.class);
UserRepository userRepo = mock(UserRepository.class);
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
CommentRepository commentRepo = mock(CommentRepository.class);
ReactionRepository reactionRepo = mock(ReactionRepository.class);
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();
author.setId(1L);
User winner = new User();
winner.setId(2L);
LotteryPost lp = new LotteryPost();
lp.setId(1L);
lp.setAuthor(author);
lp.setTitle("L");
lp.setPrizeCount(1);
lp.getParticipants().add(winner);
when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp));
service.finalizeLottery(1L);
verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull());
verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull());
}
} }

View File

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

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

@@ -2,3 +2,4 @@ node_modules
.nuxt .nuxt
dist dist
.output .output
.env

View File

@@ -1,7 +1,11 @@
<template> <template>
<div id="app"> <div id="app">
<div class="header-container"> <div class="header-container">
<HeaderComponent @toggle-menu="menuVisible = !menuVisible" :show-menu-btn="!hideMenu" /> <HeaderComponent
ref="header"
@toggle-menu="menuVisible = !menuVisible"
:show-menu-btn="!hideMenu"
/>
</div> </div>
<div class="main-container"> <div class="main-container">
@@ -11,24 +15,27 @@
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }"> <div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
<NuxtPage keepalive /> <NuxtPage keepalive />
</div> </div>
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i>
</div>
</div> </div>
<GlobalPopups /> <GlobalPopups />
</div> </div>
</template> </template>
<script> <script setup>
import HeaderComponent from '~/components/HeaderComponent.vue' import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue' import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue' import GlobalPopups from '~/components/GlobalPopups.vue'
import { useIsMobile } from '~/utils/screen'
export default { const isMobile = useIsMobile()
name: 'App', const menuVisible = ref(!isMobile.value)
components: { HeaderComponent, MenuComponent, GlobalPopups },
setup() { const showNewPostIcon = computed(() => useRoute().path === '/')
const isMobile = useIsMobile()
const menuVisible = ref(!isMobile.value) const hideMenu = computed(() => {
const hideMenu = computed(() => {
return [ return [
'/login', '/login',
'/signup', '/signup',
@@ -40,24 +47,45 @@ export default {
'/forgot-password', '/forgot-password',
'/google-callback', '/google-callback',
].includes(useRoute().path) ].includes(useRoute().path)
}) })
onMounted(() => { const header = useTemplateRef('header')
onMounted(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768 menuVisible.value = window.innerWidth > 768
} }
}) })
const handleMenuOutside = () => { const handleMenuOutside = (event) => {
if (isMobile.value) menuVisible.value = false const btn = header.value.$refs.menuBtn
if (btn && (btn === event.target || btn.contains(event.target))) {
return // 如果是菜单按钮的点击,不处理关闭
} }
return { menuVisible, hideMenu, handleMenuOutside } if (isMobile.value) {
}, menuVisible.value = false
}
}
const goToNewPost = () => {
navigateTo('/new-post', { replace: false })
} }
</script> </script>
<style src="~/assets/global.css"></style> <style src="~/assets/global.css"></style>
<style> <style>
/* 页面过渡效果 */
.page-enter-active,
.page-leave-active {
transition: all 0.4s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
filter: blur(10px);
}
.header-container { .header-container {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -90,6 +118,24 @@ export default {
margin: 0 auto; margin: 0 auto;
} }
.app-new-post-icon {
background-color: var(--new-post-icon-color);
color: white;
width: 60px;
height: 60px;
border-radius: 50%;
position: fixed;
bottom: 40px;
right: 20px;
font-size: 20px;
cursor: pointer;
z-index: 1000;
display: flex;
backdrop-filter: var(--blur-5);
justify-content: center;
align-items: center;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.content, .content,
.content.menu-open { .content.menu-open {

View File

@@ -0,0 +1,143 @@
/* Maple Mono - Thin 100 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
font-weight: 100;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Thin Italic 100 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
font-weight: 100;
font-style: italic;
font-display: swap;
}
/* Maple Mono - ExtraLight 200 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
font-weight: 200;
font-style: normal;
font-display: swap;
}
/* Maple Mono - ExtraLight Italic 200 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
font-weight: 200;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Light 300 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Light Italic 300 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
font-weight: 300;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Regular 400 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Italic 400 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Medium 500 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Medium Italic 500 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
font-weight: 500;
font-style: italic;
font-display: swap;
}
/* Maple Mono - SemiBold 600 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Maple Mono - SemiBold Italic 600 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
font-weight: 600;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Bold 700 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Bold Italic 700 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
font-display: swap;
}
/* Maple Mono - ExtraBold 800 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
font-weight: 800;
font-style: normal;
font-display: swap;
}
/* Maple Mono - ExtraBold Italic 800 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
font-weight: 800;
font-style: italic;
font-display: swap;
}

View File

@@ -2,27 +2,35 @@
--primary-color-hover: rgb(9, 95, 105); --primary-color-hover: rgb(9, 95, 105);
--primary-color: rgb(10, 110, 120); --primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5); --primary-color-disabled: rgba(93, 152, 156, 0.5);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-height: 60px; --header-height: 60px;
--header-background-color: white; --header-background-color: white;
--header-border-color: lightgray; --header-border-color: lightgray;
--header-text-color: black; --header-text-color: black;
--menu-background-color: white; --blur-1: blur(1px);
--blur-2: blur(2px);
--blur-4: blur(4px);
--blur-5: blur(5px);
--blur-10: blur(10px);
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
--app-menu-background-color: white;
--background-color: white; --background-color: white;
/* --background-color-blur: rgba(255, 255, 255, 0.57); */ --background-color-blur: rgba(255, 255, 255, 0.57);
--background-color-blur: var(--background-color);
--menu-border-color: lightgray; --menu-border-color: lightgray;
--normal-border-color: lightgray; --normal-border-color: lightgray;
--menu-selected-background-color: rgba(208, 250, 255, 0.659); --menu-selected-background-color: rgba(208, 250, 255, 0.659);
--menu-text-color: black; --menu-text-color: black;
--scroller-background-color: rgba(130, 175, 180, 0.5); --scroller-background-color: rgba(130, 175, 180, 0.5);
--normal-background-color: rgb(241, 241, 241); /* --normal-background-color: rgb(241, 241, 241); */
--normal-background-color: white;
--lottery-background-color: rgb(241, 241, 241); --lottery-background-color: rgb(241, 241, 241);
--code-highlight-background-color: rgb(241, 241, 241);
--login-background-color: rgb(248, 248, 248); --login-background-color: rgb(248, 248, 248);
--login-background-color-hover: #e0e0e0; --login-background-color-hover: #e0e0e0;
--text-color: black; --text-color: black;
--blockquote-text-color: #6a737d; --blockquote-text-color: #6a737d;
--menu-width: 200px; --menu-width: 200px;
--page-max-width: 1200px; --page-max-width: 1400px;
--page-max-width-mobile: 900px; --page-max-width-mobile: 900px;
--article-info-background-color: #f0f0f0; --article-info-background-color: #f0f0f0;
--activity-card-background-color: #fafafa; --activity-card-background-color: #fafafa;
@@ -33,8 +41,9 @@
--header-border-color: #555; --header-border-color: #555;
--primary-color: rgb(17, 182, 197); --primary-color: rgb(17, 182, 197);
--primary-color-hover: rgb(13, 137, 151); --primary-color-hover: rgb(13, 137, 151);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-text-color: white; --header-text-color: white;
--menu-background-color: #333; --app-menu-background-color: #333;
--background-color: #333; --background-color: #333;
/* --background-color-blur: #333333a4; */ /* --background-color-blur: #333333a4; */
--background-color-blur: var(--background-color); --background-color-blur: var(--background-color);
@@ -42,8 +51,10 @@
--normal-border-color: #555; --normal-border-color: #555;
--menu-selected-background-color: rgba(255, 255, 255, 0.1); --menu-selected-background-color: rgba(255, 255, 255, 0.1);
--menu-text-color: white; --menu-text-color: white;
--normal-background-color: #000000; /* --normal-background-color: #000000; */
--normal-background-color: #333;
--lottery-background-color: #4e4e4e; --lottery-background-color: #4e4e4e;
--code-highlight-background-color: #262b35;
--login-background-color: #575757; --login-background-color: #575757;
--login-background-color-hover: #717171; --login-background-color-hover: #717171;
--text-color: #eee; --text-color: #eee;
@@ -52,6 +63,15 @@
--activity-card-background-color: #585858; --activity-card-background-color: #585858;
} }
:root[data-frosted='off'] {
--blur-1: none;
--blur-2: none;
--blur-4: none;
--blur-5: none;
--blur-10: none;
--background-color-blur: var(--background-color);
}
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -131,13 +151,43 @@ body {
} }
.info-content-text pre { .info-content-text pre {
background-color: var(--normal-background-color); display: flex;
background-color: var(--code-highlight-background-color);
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
line-height: 1.5; line-height: 1.5;
position: relative; position: relative;
} }
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
width: 2em;
font-size: 13px;
position: sticky;
flex-shrink: 0;
font-family: 'Maple Mono', monospace;
margin: 1em 0;
color: #888;
border-right: 1px solid #888;
box-sizing: border-box;
padding-right: 0.5em;
text-align: end;
}
.info-content-text pre .line-numbers .line-number::before {
content: counter(line-number);
counter-increment: line-number;
}
.info-content-text code {
font-family: 'Maple Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: no-wrap;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
}
.copy-code-btn { .copy-code-btn {
position: absolute; position: absolute;
top: 4px; top: 4px;
@@ -156,20 +206,13 @@ body {
opacity: 1; opacity: 1;
} }
.info-content-text code { .about-content a,
font-family: 'Roboto Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: pre-wrap;
background-color: var(--normal-background-color);
color: var(--text-color);
}
.info-content-text a { .info-content-text a {
color: var(--primary-color); color: var(--primary-color);
text-decoration: none; text-decoration: none;
} }
.about-content a:hover,
.info-content-text a:hover { .info-content-text a:hover {
text-decoration: underline; text-decoration: underline;
} }
@@ -267,7 +310,7 @@ body {
} }
.info-content-text pre { .info-content-text pre {
line-height: 1.1; line-height: 1.5;
} }
.vditor-panel { .vditor-panel {
@@ -276,6 +319,29 @@ body {
} }
} }
/* Transition API */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
[data-theme='dark']::view-transition-old(root) {
z-index: 2147483646;
}
[data-theme='dark']::view-transition-new(root) {
z-index: 1;
}
/* NProgress styles */ /* NProgress styles */
#nprogress { #nprogress {
pointer-events: none; pointer-events: none;

View File

@@ -37,8 +37,10 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { getToken } from '~/utils/auth' import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const props = defineProps({ const props = defineProps({
medals: { medals: {

View File

@@ -11,29 +11,20 @@
</BasePopup> </BasePopup>
</template> </template>
<script> <script setup>
import BasePopup from '~/components/BasePopup.vue' import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default { const props = defineProps({
name: 'ActivityPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
icon: String, icon: String,
text: String, text: String,
}, })
emits: ['close'], const emit = defineEmits(['close'])
setup(props, { emit }) { const gotoActivity = async () => {
const router = useRouter()
const gotoActivity = () => {
emit('close') emit('close')
router.push('/activities') await navigateTo('/activities', { replace: true })
}
const close = () => emit('close')
return { gotoActivity, close }
},
} }
const close = () => emit('close')
</script> </script>
<style scoped> <style scoped>

View File

@@ -12,25 +12,15 @@
</div> </div>
</template> </template>
<script> <script setup>
import { useRouter } from 'vue-router' const props = defineProps({
export default {
name: 'ArticleCategory',
props: {
category: { type: Object, default: null }, category: { type: Object, default: null },
}, })
setup(props) {
const router = useRouter() const gotoCategory = async () => {
const gotoCategory = () => {
if (!props.category) return if (!props.category) return
const value = encodeURIComponent(props.category.id ?? props.category.name) const value = encodeURIComponent(props.category.id ?? props.category.name)
router.push({ path: '/', query: { category: value } }).then(() => { await navigateTo({ path: '/', query: { category: value } }, { replace: true })
window.location.reload()
})
}
return { gotoCategory }
},
} }
</script> </script>

View File

@@ -17,24 +17,14 @@
</div> </div>
</template> </template>
<script> <script setup>
import { useRouter } from 'vue-router' defineProps({
export default {
name: 'ArticleTags',
props: {
tags: { type: Array, default: () => [] }, tags: { type: Array, default: () => [] },
}, })
setup() {
const router = useRouter() const gotoTag = async (tag) => {
const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name) const value = encodeURIComponent(tag.id ?? tag.name)
router.push({ path: '/', query: { tags: value } }).then(() => { await navigateTo({ path: '/', query: { tags: value } }, { replace: true })
window.location.reload()
})
}
return { gotoTag }
},
} }
</script> </script>

View File

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

View File

@@ -26,49 +26,43 @@
</Dropdown> </Dropdown>
</template> </template>
<script> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { API_BASE_URL } from '~/main'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const props = defineProps({
name: 'CategorySelect',
components: { Dropdown },
props: {
modelValue: { type: [String, Number], default: '' }, modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] }, options: { type: Array, default: () => [] },
}, })
emits: ['update:modelValue'],
setup(props, { emit }) {
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
watch( const emit = defineEmits(['update:modelValue'])
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options, () => props.options,
(val) => { (val) => {
providedOptions.value = Array.isArray(val) ? [...val] : [] providedOptions.value = Array.isArray(val) ? [...val] : []
}, },
) )
const fetchCategories = async () => { const fetchCategories = async () => {
const res = await fetch(`${API_BASE_URL}/api/categories`) const res = await fetch(`${API_BASE_URL}/api/categories`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return [{ id: '', name: '无分类' }, ...data] return [{ id: '', name: '无分类' }, ...data]
} }
const isImageIcon = (icon) => { const isImageIcon = (icon) => {
if (!icon) return false if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/') return /^https?:\/\//.test(icon) || icon.startsWith('/')
} }
const selected = computed({ const selected = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
}) })
return { fetchCategories, selected, isImageIcon, providedOptions }
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -22,6 +22,7 @@
:to="`/users/${comment.userId}?tab=achievements`" :to="`/users/${comment.userId}?tab=achievements`"
>{{ getMedalTitle(comment.medal) }}</router-link >{{ getMedalTitle(comment.medal) }}</router-link
> >
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2"> <span v-if="level >= 2">
<i class="fas fa-reply reply-icon"></i> <i class="fas fa-reply reply-icon"></i>
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span> <span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
@@ -74,6 +75,7 @@
:comment="item" :comment="item"
:level="level + 1" :level="level + 1"
:default-show-replies="item.openReplies" :default-show-replies="item.openReplies"
:post-author-id="postAuthorId"
/> />
</template> </template>
</BaseTimeline> </BaseTimeline>
@@ -88,11 +90,10 @@
</div> </div>
</template> </template>
<script> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox' import VueEasyLightbox from 'vue-easy-lightbox'
import { useRouter } from 'vue-router' import { toast } from '~/main'
import { API_BASE_URL, toast } from '~/main'
import { authState, getToken } from '~/utils/auth' import { authState, getToken } from '~/utils/auth'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown' import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal' import { getMedalTitle } from '~/utils/medal'
@@ -100,13 +101,11 @@ import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import CommentEditor from '~/components/CommentEditor.vue' import CommentEditor from '~/components/CommentEditor.vue'
import DropdownMenu from '~/components/DropdownMenu.vue' import DropdownMenu from '~/components/DropdownMenu.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue' import ReactionsGroup from '~/components/ReactionsGroup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const CommentItem = { const props = defineProps({
name: 'CommentItem',
emits: ['deleted'],
props: {
comment: { comment: {
type: Object, type: Object,
required: true, required: true,
@@ -119,39 +118,45 @@ const CommentItem = {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
postAuthorId: {
type: [Number, String],
required: true,
}, },
setup(props, { emit }) { })
const router = useRouter()
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies) const emit = defineEmits(['deleted'])
watch(
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
watch(
() => props.defaultShowReplies, () => props.defaultShowReplies,
(val) => { (val) => {
showReplies.value = props.level === 0 ? true : val showReplies.value = props.level === 0 ? true : val
}, },
) )
const showEditor = ref(false) const showEditor = ref(false)
const editorWrapper = ref(null) const editorWrapper = ref(null)
const isWaitingForReply = ref(false) const isWaitingForReply = ref(false)
const lightboxVisible = ref(false) const lightboxVisible = ref(false)
const lightboxIndex = ref(0) const lightboxIndex = ref(0)
const lightboxImgs = ref([]) const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn) const loggedIn = computed(() => authState.loggedIn)
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0) const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || [])) const replyCount = computed(() => countReplies(props.comment.reply || []))
const toggleReplies = () => {
const toggleReplies = () => {
showReplies.value = !showReplies.value showReplies.value = !showReplies.value
} }
const toggleEditor = () => {
const toggleEditor = () => {
showEditor.value = !showEditor.value showEditor.value = !showEditor.value
if (showEditor.value) { if (showEditor.value) {
setTimeout(() => { setTimeout(() => {
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 100) }, 100)
} }
} }
// 合并所有子回复为一个扁平数组 const flattenReplies = (list) => {
const flattenReplies = (list) => {
let result = [] let result = []
for (const r of list) { for (const r of list) {
result.push(r) result.push(r)
@@ -160,24 +165,34 @@ const CommentItem = {
} }
} }
return result return result
} }
const replyList = computed(() => { const replyList = computed(() => {
if (props.level < 1) { if (props.level < 1) {
return props.comment.reply return props.comment.reply
} }
return flattenReplies(props.comment.reply || []) return flattenReplies(props.comment.reply || [])
}) })
const isAuthor = computed(() => authState.username === props.comment.userName) const isAuthor = computed(() => authState.username === props.comment.userName)
const isAdmin = computed(() => authState.role === 'ADMIN') const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
const commentMenuItems = computed(() => const isAdmin = computed(() => authState.role === 'ADMIN')
isAuthor.value || isAdmin.value const commentMenuItems = computed(() => {
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] const items = []
: [], if (isAuthor.value || isAdmin.value) {
) items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() })
const deleteComment = async () => { }
if (isAdmin.value || isPostAuthor.value) {
if (props.comment.pinned) {
items.push({ text: '取消置顶', onClick: () => unpinComment() })
} else {
items.push({ text: '置顶', onClick: () => pinComment() })
}
}
return items
})
const deleteComment = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -195,8 +210,8 @@ const CommentItem = {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const submitReply = async (parentUserName, text, clear) => { const submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return if (!text.trim()) return
isWaitingForReply.value = true isWaitingForReply.value = true
const token = getToken() const token = getToken()
@@ -236,11 +251,11 @@ const CommentItem = {
reply: [], reply: [],
openReplies: false, openReplies: false,
src: r.author.avatar, src: r.author.avatar,
iconClick: () => router.push(`/users/${r.author.id}`), iconClick: () => navigateTo(`/users/${r.author.id}`),
})), })),
openReplies: false, openReplies: false,
src: data.author.avatar, src: data.author.avatar,
iconClick: () => router.push(`/users/${data.author.id}`), iconClick: () => navigateTo(`/users/${data.author.id}`),
}) })
clear() clear()
showEditor.value = false showEditor.value = false
@@ -256,14 +271,57 @@ const CommentItem = {
} finally { } finally {
isWaitingForReply.value = false isWaitingForReply.value = false
} }
}
const pinComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
} }
const copyCommentLink = () => { const url = isAdmin.value
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin`
: `${API_BASE_URL}/api/comments/${props.comment.id}/pin`
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
props.comment.pinned = true
toast.success('已置顶')
} else {
toast.error('操作失败')
}
}
const unpinComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url = isAdmin.value
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin`
: `${API_BASE_URL}/api/comments/${props.comment.id}/unpin`
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
props.comment.pinned = false
toast.success('已取消置顶')
} else {
toast.error('操作失败')
}
}
const copyCommentLink = () => {
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}` const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
navigator.clipboard.writeText(link).then(() => { navigator.clipboard.writeText(link).then(() => {
toast.success('已复制') toast.success('已复制')
}) })
} }
const handleContentClick = (e) => {
const handleContentClick = (e) => {
handleMarkdownClick(e) handleMarkdownClick(e)
if (e.target.tagName === 'IMG') { if (e.target.tagName === 'IMG') {
const container = e.target.parentNode const container = e.target.parentNode
@@ -272,42 +330,7 @@ const CommentItem = {
lightboxIndex.value = imgs.indexOf(e.target.src) lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true lightboxVisible.value = true
} }
}
return {
showReplies,
toggleReplies,
showEditor,
toggleEditor,
submitReply,
copyCommentLink,
renderMarkdown,
isWaitingForReply,
commentMenuItems,
deleteComment,
lightboxVisible,
lightboxIndex,
lightboxImgs,
handleContentClick,
loggedIn,
replyCount,
replyList,
getMedalTitle,
editorWrapper,
}
},
} }
CommentItem.components = {
CommentItem,
CommentEditor,
BaseTimeline,
ReactionsGroup,
DropdownMenu,
VueEasyLightbox,
LoginOverlay,
}
export default CommentItem
</script> </script>
<style scoped> <style scoped>
@@ -370,6 +393,12 @@ export default CommentItem
margin-left: 10px; margin-left: 10px;
} }
.pin-icon {
font-size: 12px;
margin-left: 10px;
opacity: 0.6;
}
@keyframes highlight { @keyframes highlight {
from { from {
background-color: yellow; background-color: yellow;

View File

@@ -114,7 +114,7 @@
</template> </template>
<script> <script>
import { ref, computed, watch, onMounted } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
export default { export default {
@@ -312,7 +312,7 @@ export default {
border: none; border: none;
outline: none; outline: none;
margin-left: 5px; margin-left: 5px;
background-color: var(--menu-background-color); background-color: var(--app-menu-background-color);
color: var(--text-color); color: var(--text-color);
} }
@@ -352,7 +352,7 @@ export default {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: var(--menu-background-color); background-color: var(--app-menu-background-color);
z-index: 1300; z-index: 1300;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -3,6 +3,7 @@
<div class="dropdown-trigger" @click="toggle"> <div class="dropdown-trigger" @click="toggle">
<slot name="trigger"></slot> <slot name="trigger"></slot>
</div> </div>
<Transition name="dropdown-menu">
<div v-if="visible" class="dropdown-menu-container"> <div v-if="visible" class="dropdown-menu-container">
<div <div
v-for="(item, idx) in items" v-for="(item, idx) in items"
@@ -14,11 +15,12 @@
{{ item.text }} {{ item.text }}
</div> </div>
</div> </div>
</Transition>
</div> </div>
</template> </template>
<script> <script>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
export default { export default {
name: 'DropdownMenu', name: 'DropdownMenu',
props: { props: {
@@ -61,17 +63,28 @@ export default {
position: relative; position: relative;
display: inline-block; display: inline-block;
} }
.dropdown-trigger { .dropdown-trigger {
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.dropdown-menu-enter-active,
.dropdown-menu-leave-active {
transition: all 0.4s;
}
.dropdown-menu-enter-from,
.dropdown-menu-leave-to {
opacity: 0;
transform: translateY(-16px);
}
.dropdown-menu-container { .dropdown-menu-container {
position: absolute; position: absolute;
top: 100%; top: 100%;
right: 0; right: 0;
background-color: var(--menu-background-color); background-color: var(--app-menu-background-color);
border: 1px solid var(--normal-border-color); border: 1px solid var(--normal-border-color);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
border-radius: 8px; border-radius: 8px;
@@ -82,7 +95,9 @@ export default {
.dropdown-item { .dropdown-item {
padding: 8px 16px; padding: 8px 16px;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
} }
.dropdown-item:hover { .dropdown-item:hover {
background-color: var(--menu-selected-background-color); background-color: var(--menu-selected-background-color);
} }

View File

@@ -11,36 +11,32 @@
</div> </div>
</template> </template>
<script> <script setup>
import ActivityPopup from '~/components/ActivityPopup.vue' import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue' import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue' import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import { API_BASE_URL } from '~/main'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
export default { const config = useRuntimeConfig()
name: 'GlobalPopups', const API_BASE_URL = config.public.apiBaseUrl
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
data() {
return {
showMilkTeaPopup: false,
milkTeaIcon: '',
showNotificationPopup: false,
showMedalPopup: false,
newMedals: [],
}
},
async mounted() {
await this.checkMilkTeaActivity()
if (this.showMilkTeaPopup) return
await this.checkNotificationSetting() const showMilkTeaPopup = ref(false)
if (this.showNotificationPopup) return const milkTeaIcon = ref('')
const showNotificationPopup = ref(false)
const showMedalPopup = ref(false)
const newMedals = ref([])
await this.checkNewMedals() onMounted(async () => {
}, await checkMilkTeaActivity()
methods: { if (showMilkTeaPopup.value) return
async checkMilkTeaActivity() {
await checkNotificationSetting()
if (showNotificationPopup.value) return
await checkNewMedals()
})
const checkMilkTeaActivity = async () => {
if (!process.client) return if (!process.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return if (localStorage.getItem('milkTeaActivityPopupShown')) return
try { try {
@@ -49,33 +45,33 @@ export default {
const list = await res.json() const list = await res.json()
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended) const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
if (a) { if (a) {
this.milkTeaIcon = a.icon milkTeaIcon.value = a.icon
this.showMilkTeaPopup = true showMilkTeaPopup.value = true
} }
} }
} catch (e) { } catch (e) {
// ignore network errors // ignore network errors
} }
}, }
closeMilkTeaPopup() { const closeMilkTeaPopup = () => {
if (!process.client) return if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true') localStorage.setItem('milkTeaActivityPopupShown', 'true')
this.showMilkTeaPopup = false showMilkTeaPopup.value = false
this.checkNotificationSetting() checkNotificationSetting()
}, }
async checkNotificationSetting() { const checkNotificationSetting = async () => {
if (!process.client) return if (!process.client) return
if (!authState.loggedIn) return if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return if (localStorage.getItem('notificationSettingPopupShown')) return
this.showNotificationPopup = true showNotificationPopup.value = true
}, }
closeNotificationPopup() { const closeNotificationPopup = () => {
if (!process.client) return if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true') localStorage.setItem('notificationSettingPopupShown', 'true')
this.showNotificationPopup = false showNotificationPopup.value = false
this.checkNewMedals() checkNewMedals()
}, }
async checkNewMedals() { const checkNewMedals = async () => {
if (!process.client) return if (!process.client) return
if (!authState.loggedIn || !authState.userId) return if (!authState.loggedIn || !authState.userId) return
try { try {
@@ -85,21 +81,19 @@ export default {
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]') const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
const m = medals.filter((i) => i.completed && !seen.includes(i.type)) const m = medals.filter((i) => i.completed && !seen.includes(i.type))
if (m.length > 0) { if (m.length > 0) {
this.newMedals = m newMedals.value = m
this.showMedalPopup = true showMedalPopup.value = true
} }
} }
} catch (e) { } catch (e) {
// ignore errors // ignore errors
} }
}, }
closeMedalPopup() { const closeMedalPopup = () => {
if (!process.client) return if (!process.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]')) const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
this.newMedals.forEach((m) => seen.add(m.type)) newMedals.value.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen])) localStorage.setItem('seenMedals', JSON.stringify([...seen]))
this.showMedalPopup = false showMedalPopup.value = false
},
},
} }
</script> </script>

View File

@@ -3,12 +3,12 @@
<div class="header-content"> <div class="header-content">
<div class="header-content-left"> <div class="header-content-left">
<div v-if="showMenuBtn" class="menu-btn-wrapper"> <div v-if="showMenuBtn" class="menu-btn-wrapper">
<button class="menu-btn" @click="$emit('toggle-menu')"> <button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span> <span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
</div> </div>
<div class="logo-container" @click="goToHome"> <NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img <img
alt="OpenIsle" alt="OpenIsle"
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
@@ -16,15 +16,26 @@
height="60" height="60"
/> />
<div class="logo-text">OpenIsle</div> <div class="logo-text">OpenIsle</div>
</div> </NuxtLink>
</div> </div>
<ClientOnly> <ClientOnly>
<div v-if="isLogin" class="header-content-right"> <div class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search"> <div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
</div> </div>
<DropdownMenu ref="userMenu" :items="headerMenuItems">
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
<i :class="iconClass"></i>
</div>
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i>
</div>
</ToolTip>
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
<template #trigger> <template #trigger>
<div class="avatar-container"> <div class="avatar-container">
<img class="avatar-img" :src="avatar" alt="avatar" /> <img class="avatar-img" :src="avatar" alt="avatar" />
@@ -32,15 +43,12 @@
</div> </div>
</template> </template>
</DropdownMenu> </DropdownMenu>
</div>
<div v-else class="header-content-right"> <div v-if="!isLogin" class="auth-btns">
<div v-if="isMobile" class="search-icon" @click="search">
<i class="fas fa-search"></i>
</div>
<div class="header-content-item-main" @click="goToLogin">登录</div> <div class="header-content-item-main" @click="goToLogin">登录</div>
<div class="header-content-item-secondary" @click="goToSignup">注册</div> <div class="header-content-item-secondary" @click="goToSignup">注册</div>
</div> </div>
</div>
</ClientOnly> </ClientOnly>
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" /> <SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
@@ -48,60 +56,53 @@
</header> </header>
</template> </template>
<script> <script setup>
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { watch, nextTick, ref, computed } from 'vue'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import DropdownMenu from '~/components/DropdownMenu.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router'
import { ClientOnly } from '#components' import { ClientOnly } from '#components'
import { computed, nextTick, ref, watch } from 'vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
export default { const props = defineProps({
name: 'HeaderComponent',
components: { DropdownMenu, SearchDropdown },
props: {
showMenuBtn: { showMenuBtn: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}, })
setup() {
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
const router = useRouter()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const goToHome = () => { const isLogin = computed(() => authState.loggedIn)
router.push('/').then(() => { const isMobile = useIsMobile()
window.location.reload() const unreadCount = computed(() => notificationState.unreadCount)
}) const avatar = ref('')
} const showSearch = ref(false)
const search = () => { const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const search = () => {
showSearch.value = true showSearch.value = true
nextTick(() => { nextTick(() => {
searchDropdown.value.toggle() searchDropdown.value.toggle()
}) })
} }
const closeSearch = () => { const closeSearch = () => {
nextTick(() => { nextTick(() => {
showSearch.value = false showSearch.value = false
}) })
} }
const goToLogin = () => { const goToLogin = () => {
router.push('/login') navigateTo('/login', { replace: true })
} }
const goToSettings = () => { const goToSettings = () => {
router.push('/settings') navigateTo('/settings', { replace: true })
} }
const goToProfile = async () => { const goToProfile = async () => {
if (!authState.loggedIn) { if (!authState.loggedIn) {
router.push('/login') navigateTo('/login', { replace: true })
return return
} }
let id = authState.username || authState.userId let id = authState.username || authState.userId
@@ -112,24 +113,45 @@ export default {
} }
} }
if (id) { if (id) {
router.push(`/users/${id}`) navigateTo(`/users/${id}`, { replace: true })
} }
} }
const goToSignup = () => { const goToSignup = () => {
router.push('/signup') navigateTo('/signup', { replace: true })
} }
const goToLogout = () => { const goToLogout = () => {
clearToken() clearToken()
this.$router.push('/login') navigateTo('/login', { replace: true })
} }
const headerMenuItems = computed(() => [ 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: goToSettings },
{ text: '个人主页', onClick: goToProfile }, { text: '个人主页', onClick: goToProfile },
{ text: '退出', onClick: goToLogout }, { text: '退出', onClick: goToLogout },
]) ])
onMounted(async () => { /** 其余逻辑保持不变 */
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 () => { const updateAvatar = async () => {
if (authState.loggedIn) { if (authState.loggedIn) {
const user = await loadCurrentUser() const user = await loadCurrentUser()
@@ -156,46 +178,17 @@ export default {
await updateUnread() await updateUnread()
}, },
) )
})
watch(
() => router.currentRoute.value.fullPath,
() => {
if (userMenu.value) userMenu.value.close()
showSearch.value = false
},
)
})
return {
isLogin,
isMobile,
headerMenuItems,
unreadCount,
goToHome,
search,
closeSearch,
goToLogin,
goToSettings,
goToProfile,
goToSignup,
goToLogout,
showSearch,
searchDropdown,
userMenu,
avatar,
}
},
}
</script> </script>
<style scoped> <style scoped>
.header { .header {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: center;
height: var(--header-height); height: var(--header-height);
background-color: var(--background-color-blur); background-color: var(--background-color-blur);
backdrop-filter: blur(10px); backdrop-filter: var(--blur-10);
color: var(--header-text-color); color: var(--header-text-color);
border-bottom: 1px solid var(--header-border-color); border-bottom: 1px solid var(--header-border-color);
} }
@@ -206,17 +199,18 @@ export default {
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
text-decoration: none;
color: inherit;
} }
.header-content { .header-content {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0 auto;
max-width: var(--page-max-width); max-width: var(--page-max-width);
backdrop-filter: var(--blur-10);
} }
.header-content-left { .header-content-left {
@@ -226,6 +220,14 @@ export default {
} }
.header-content-right { .header-content-right {
display: flex;
margin-left: auto;
flex-direction: row;
align-items: center;
gap: 20px;
}
.auth-btns {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -307,7 +309,13 @@ export default {
background-color: var(--menu-selected-background-color); background-color: var(--menu-selected-background-color);
} }
.search-icon { .search-icon,
.theme-icon {
font-size: 18px;
cursor: pointer;
}
.new-post-icon {
font-size: 18px; font-size: 18px;
cursor: pointer; cursor: pointer;
} }

View File

@@ -9,18 +9,9 @@
</div> </div>
</template> </template>
<script> <script setup>
import { useRouter } from 'vue-router' const goLogin = () => {
navigateTo('/login', { replace: true })
export default {
name: 'LoginOverlay',
setup() {
const router = useRouter()
const goLogin = () => {
router.push('/login')
}
return { goLogin }
},
} }
</script> </script>
@@ -44,7 +35,7 @@ export default {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
backdrop-filter: blur(4px); backdrop-filter: var(--blur-4);
z-index: 1; z-index: 1;
} }

View File

@@ -16,33 +16,25 @@
</BasePopup> </BasePopup>
</template> </template>
<script> <script setup>
import BasePopup from '~/components/BasePopup.vue' import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
export default { defineProps({
name: 'MedalPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
medals: { type: Array, default: () => [] }, medals: { type: Array, default: () => [] },
}, })
emits: ['close'], const emit = defineEmits(['close'])
setup(props, { emit }) {
const router = useRouter() const gotoMedals = () => {
const gotoMedals = () => {
emit('close') emit('close')
if (authState.username) { if (authState.username) {
router.push(`/users/${authState.username}?tab=achievements`) navigateTo(`/users/${authState.username}?tab=achievements`, { replace: true })
} else { } else {
router.push('/') navigateTo('/', { replace: true })
} }
}
const close = () => emit('close')
return { gotoMedals, close }
},
} }
const close = () => emit('close')
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,11 +1,21 @@
<template> <template>
<transition name="slide"> <transition name="slide">
<nav v-if="visible" class="menu"> <nav v-if="visible" class="menu">
<div class="menu-content">
<div class="menu-item-container"> <div class="menu-item-container">
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick"> <NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
<i class="menu-item-icon fas fa-hashtag"></i> <i class="menu-item-icon fas fa-hashtag"></i>
<span class="menu-item-text">话题</span> <span class="menu-item-text">话题</span>
</NuxtLink> </NuxtLink>
<NuxtLink
class="menu-item"
exact-active-class="selected"
to="/new-post"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-edit"></i>
<span class="menu-item-text">发帖</span>
</NuxtLink>
<NuxtLink <NuxtLink
class="menu-item" class="menu-item"
exact-active-class="selected" exact-active-class="selected"
@@ -47,13 +57,17 @@
<span class="menu-item-text">站点统计</span> <span class="menu-item-text">站点统计</span>
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
v-if="authState.loggedIn"
class="menu-item" class="menu-item"
exact-active-class="selected" exact-active-class="selected"
to="/new-post" to="/points"
@click="handleItemClick" @click="handleItemClick"
> >
<i class="menu-item-icon fas fa-edit"></i> <i class="menu-item-icon fas fa-coins"></i>
<span class="menu-item-text">发帖</span> <span class="menu-item-text">
积分商城
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
</span>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -113,59 +127,70 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 解决动态样式的水合错误 -->
<ClientOnly v-if="!isMobile">
<div class="menu-footer"> <div class="menu-footer">
<div class="menu-footer-btn" @click="cycleTheme"> <div class="menu-footer-btn" @click="cycleTheme">
<i :class="iconClass"></i> <i :class="iconClass"></i>
</div> </div>
</div> </div>
</ClientOnly>
</nav> </nav>
</transition> </transition>
</template> </template>
<script> <script setup>
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme' import { computed, onMounted, ref, watch } from 'vue'
import { authState } from '~/utils/auth' import { authState, fetchCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification' import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { ref, computed, watch, onMounted } from 'vue' import { useIsMobile } from '~/utils/screen'
import { API_BASE_URL } from '~/main' import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
export default { const isMobile = useIsMobile()
name: 'MenuComponent',
props: { const config = useRuntimeConfig()
visible: { const API_BASE_URL = config.public.apiBaseUrl
type: Boolean,
default: true, const props = defineProps({
visible: { type: Boolean, default: true },
})
const emit = defineEmits(['item-click'])
const categoryOpen = ref(true)
const tagOpen = ref(true)
const myPoint = ref(null)
/** ✅ 用 useAsyncData 替换原生 fetch避免 SSR+CSR 二次请求 */
const {
data: categoryData,
pending: isLoadingCategory,
error: categoryError,
} = await useAsyncData(
// 稳定 key避免 hydration 期误判
'menu:categories',
() => $fetch(`${API_BASE_URL}/api/categories`),
{
server: true, // SSR 预取
default: () => [], // 初始默认值,减少空判断
// 5 分钟内复用缓存,避免路由往返重复请求
staleTime: 5 * 60 * 1000,
}, },
}, )
async setup(props, { emit }) {
const router = useRouter()
const categories = ref([])
const tags = ref([])
const categoryOpen = ref(true)
const tagOpen = ref(true)
const isLoadingCategory = ref(false)
const isLoadingTag = ref(false)
const categoryData = ref([])
const tagData = ref([])
const fetchCategoryData = async () => { const {
isLoadingCategory.value = true data: tagData,
const res = await fetch(`${API_BASE_URL}/api/categories`) pending: isLoadingTag,
const data = await res.json() error: tagError,
categoryData.value = data } = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
isLoadingCategory.value = false server: true,
} default: () => [],
staleTime: 5 * 60 * 1000,
})
const fetchTagData = async () => { /** 其余逻辑保持不变 */
isLoadingTag.value = true const iconClass = computed(() => {
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
const data = await res.json()
tagData.value = data
isLoadingTag.value = false
}
const iconClass = computed(() => {
switch (themeState.mode) { switch (themeState.mode) {
case ThemeMode.DARK: case ThemeMode.DARK:
return 'fas fa-moon' return 'fas fa-moon'
@@ -174,77 +199,60 @@ export default {
default: default:
return 'fas fa-desktop' return 'fas fa-desktop'
} }
}) })
const unreadCount = computed(() => notificationState.unreadCount) const unreadCount = computed(() => notificationState.unreadCount)
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value)) const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
const shouldShowStats = computed(() => authState.role === 'ADMIN') const shouldShowStats = computed(() => authState.role === 'ADMIN')
const updateCount = async () => { 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) { if (authState.loggedIn) {
await fetchUnreadCount() await fetchUnreadCount()
} else { } else {
notificationState.unreadCount = 0 notificationState.unreadCount = 0
} }
} }
onMounted(async () => { onMounted(async () => {
await updateCount() await Promise.all([updateCount(), loadPoint()])
watch(() => authState.loggedIn, updateCount) // 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
}) watch(
() => authState.loggedIn,
() => {
updateCount()
loadPoint()
},
)
})
const handleHomeClick = () => { const handleItemClick = () => {
router.push('/').then(() => {
window.location.reload()
})
}
const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click') if (window.innerWidth <= 768) emit('item-click')
} }
const isImageIcon = (icon) => { const isImageIcon = (icon) => {
if (!icon) return false if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/') return /^https?:\/\//.test(icon) || icon.startsWith('/')
} }
const gotoCategory = (c) => { const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name) const value = encodeURIComponent(c.id ?? c.name)
router.push({ path: '/', query: { category: value } }).then(() => { navigateTo({ path: '/', query: { category: value } }, { replace: true })
window.location.reload()
})
handleItemClick() handleItemClick()
} }
const gotoTag = (t) => { const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name) const value = encodeURIComponent(t.id ?? t.name)
router.push({ path: '/', query: { tags: value } }).then(() => { navigateTo({ path: '/', query: { tags: value } }, { replace: true })
window.location.reload()
})
handleItemClick() handleItemClick()
}
await Promise.all([fetchCategoryData(), fetchTagData()])
return {
categoryData,
tagData,
categoryOpen,
tagOpen,
isLoadingCategory,
isLoadingTag,
iconClass,
unreadCount,
showUnreadCount,
shouldShowStats,
cycleTheme,
handleHomeClick,
handleItemClick,
isImageIcon,
gotoCategory,
gotoTag,
}
},
} }
</script> </script>
@@ -252,20 +260,29 @@ export default {
.menu { .menu {
position: sticky; position: sticky;
top: var(--header-height); top: var(--header-height);
width: 200px; width: 220px;
background-color: var(--menu-background-color); background-color: var(--app-menu-background-color);
height: calc(100vh - 20px - var(--header-height)); height: calc(100vh - 20px - var(--header-height));
border-right: 1px solid var(--menu-border-color); border-right: 1px solid var(--menu-border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 10px;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
backdrop-filter: var(--blur-10);
} }
.menu-item-container { .menu-content {
width: 100%;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
padding: 10px 10px 0 10px;
} }
/* .menu-item-container { */
/**/
/* } */
.menu-item { .menu-item {
padding: 4px 10px; padding: 4px 10px;
text-decoration: none; text-decoration: none;
@@ -304,6 +321,12 @@ export default {
font-weight: bold; font-weight: bold;
} }
.point-count {
margin-left: 4px;
font-size: 12px;
color: var(--primary-color);
}
.menu-item-icon { .menu-item-icon {
margin-right: 10px; margin-right: 10px;
opacity: 0.5; opacity: 0.5;
@@ -311,10 +334,8 @@ export default {
} }
.menu-footer { .menu-footer {
position: fixed; position: relation;
height: 30px; height: 30px;
bottom: 10px;
right: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
@@ -402,6 +423,10 @@ export default {
background-color: var(--background-color-blur); background-color: var(--background-color-blur);
} }
.menu-content {
border-radius: 20px;
}
.slide-enter-active, .slide-enter-active,
.slide-leave-active { .slide-leave-active {
transition: transition:

View File

@@ -40,66 +40,53 @@
兑换 兑换
</div> </div>
<div v-else class="redeem-button disabled">兑换</div> <div v-else class="redeem-button disabled">兑换</div>
<BasePopup :visible="dialogVisible" @close="closeDialog"> <RedeemPopup
<div class="redeem-dialog-content"> :visible="dialogVisible"
<BaseInput
textarea=""
rows="5"
v-model="contact" v-model="contact"
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)" :loading="loading"
@close="closeDialog"
@submit="submitRedeem"
/> />
<div class="redeem-actions">
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
</div>
</div>
</BasePopup>
</div> </div>
</template> </template>
<script> <script setup>
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth' import { fetchCurrentUser, getToken } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue'
import BasePopup from '~/components/BasePopup.vue'
import LevelProgress from '~/components/LevelProgress.vue' import LevelProgress from '~/components/LevelProgress.vue'
import ProgressBar from '~/components/ProgressBar.vue' import ProgressBar from '~/components/ProgressBar.vue'
import RedeemPopup from '~/components/RedeemPopup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const info = ref({ redeemCount: 0, ended: false })
name: 'MilkTeaActivityComponent', const user = ref(null)
components: { ProgressBar, LevelProgress, BaseInput, BasePopup }, const dialogVisible = ref(false)
data() { const contact = ref('')
return { const loading = ref(false)
info: { redeemCount: 0, ended: false }, const isLoadingUser = ref(true)
user: null,
dialogVisible: false, onMounted(async () => {
contact: '', await loadInfo()
loading: false, isLoadingUser.value = true
isLoadingUser: true, user.value = await fetchCurrentUser()
} isLoadingUser.value = false
}, })
async mounted() { const loadInfo = async () => {
await this.loadInfo()
this.isLoadingUser = true
this.user = await fetchCurrentUser()
this.isLoadingUser = false
},
methods: {
async loadInfo() {
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`) const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
if (res.ok) { if (res.ok) {
this.info = await res.json() info.value = await res.json()
} }
}, }
openDialog() { const openDialog = () => {
this.dialogVisible = true dialogVisible.value = true
}, }
closeDialog() { const closeDialog = () => {
this.dialogVisible = false dialogVisible.value = false
}, }
async submitRedeem() { const submitRedeem = async () => {
if (!this.contact) return if (!contact.value) return
this.loading = true loading.value = true
const token = getToken() const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, { const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
method: 'POST', method: 'POST',
@@ -107,7 +94,7 @@ export default {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ contact: this.contact }), body: JSON.stringify({ contact: contact.value }),
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@@ -116,14 +103,12 @@ export default {
} else { } else {
toast.success('兑换成功!') toast.success('兑换成功!')
} }
this.dialogVisible = false dialogVisible.value = false
await this.loadInfo() await loadInfo()
} else { } else {
toast.error('兑换失败') toast.error('兑换失败')
} }
this.loading = false loading.value = false
},
},
} }
</script> </script>
@@ -192,56 +177,6 @@ export default {
font-size: 14px; font-size: 14px;
} }
.redeem-dialog-content {
position: relative;
z-index: 2;
background-color: var(--background-color);
display: flex;
flex-direction: column;
gap: 10px;
min-width: 400px;
}
.redeem-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 20px;
align-items: center;
}
.redeem-submit-button {
background-color: var(--primary-color);
color: #fff;
padding: 10px 20px;
border-radius: 10px;
cursor: pointer;
}
.redeem-submit-button:disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.redeem-submit-button:hover {
background-color: var(--primary-color-hover);
}
.redeem-submit-button:disabled:hover {
background-color: var(--primary-color-disabled);
}
.redeem-cancel-button {
color: var(--primary-color);
border-radius: 10px;
cursor: pointer;
}
.redeem-cancel-button:hover {
text-decoration: underline;
}
.user-level-text { .user-level-text {
opacity: 0.8; opacity: 0.8;
font-size: 12px; font-size: 12px;
@@ -254,9 +189,5 @@ export default {
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
} }
.redeem-dialog-content {
min-width: 300px;
}
} }
</style> </style>

View File

@@ -11,27 +11,19 @@
</BasePopup> </BasePopup>
</template> </template>
<script> <script setup>
import BasePopup from '~/components/BasePopup.vue' import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default { defineProps({
name: 'NotificationSettingPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
}, })
emits: ['close'], const emit = defineEmits(['close'])
setup(props, { emit }) {
const router = useRouter() const gotoSetting = () => {
const gotoSetting = () => {
emit('close') emit('close')
router.push('/message?tab=control') navigateTo('/message?tab=control', { replace: true })
}
const close = () => emit('close')
return { gotoSetting, close }
},
} }
const close = () => emit('close')
</script> </script>
<style scoped> <style scoped>

View File

@@ -46,11 +46,27 @@
</div> </div>
</template> </template>
<script> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth' import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions' import { reactionEmojiMap } from '~/utils/reactions'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: { type: Array, default: () => [] },
contentType: { type: String, required: true },
contentId: { type: [Number, String], required: true },
})
watch(
() => props.modelValue,
(v) => (reactions.value = v),
)
const reactions = ref(props.modelValue)
const reactionTypes = ref([])
let cachedTypes = null let cachedTypes = null
const fetchTypes = async () => { const fetchTypes = async () => {
@@ -71,66 +87,50 @@ const fetchTypes = async () => {
return cachedTypes return cachedTypes
} }
export default { onMounted(async () => {
name: 'ReactionsGroup',
props: {
modelValue: { type: Array, default: () => [] },
contentType: { type: String, required: true },
contentId: { type: [Number, String], required: true },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const reactions = ref(props.modelValue)
watch(
() => props.modelValue,
(v) => (reactions.value = v),
)
const reactionTypes = ref([])
onMounted(async () => {
reactionTypes.value = await fetchTypes() reactionTypes.value = await fetchTypes()
}) })
const counts = computed(() => { const counts = computed(() => {
const c = {} const c = {}
for (const r of reactions.value) { for (const r of reactions.value) {
c[r.type] = (c[r.type] || 0) + 1 c[r.type] = (c[r.type] || 0) + 1
} }
return c return c
}) })
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0)) const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
const likeCount = computed(() => counts.value['LIKE'] || 0) const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) => const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username) reactions.value.some((r) => r.type === type && r.user === authState.username)
const displayedReactions = computed(() => { const displayedReactions = computed(() => {
return Object.entries(counts.value) return Object.entries(counts.value)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 3) .slice(0, 3)
.map(([type]) => ({ type })) .map(([type]) => ({ type }))
}) })
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE')) const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
const panelVisible = ref(false) const panelVisible = ref(false)
let hideTimer = null let hideTimer = null
const openPanel = () => { const openPanel = () => {
clearTimeout(hideTimer) clearTimeout(hideTimer)
panelVisible.value = true panelVisible.value = true
} }
const scheduleHide = () => { const scheduleHide = () => {
clearTimeout(hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => { hideTimer = setTimeout(() => {
panelVisible.value = false panelVisible.value = false
}, 500) }, 500)
} }
const cancelHide = () => { const cancelHide = () => {
clearTimeout(hideTimer) clearTimeout(hideTimer)
} }
const toggleReaction = async (type) => { const toggleReaction = async (type) => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -199,23 +199,6 @@ export default {
emit('update:modelValue', reactions.value) emit('update:modelValue', reactions.value)
toast.error('操作失败') toast.error('操作失败')
} }
}
return {
reactionEmojiMap,
counts,
totalCount,
likeCount,
displayedReactions,
panelTypes,
panelVisible,
openPanel,
scheduleHide,
cancelHide,
toggleReaction,
userReacted,
}
},
} }
</script> </script>

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

@@ -36,33 +36,29 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL } from '~/main'
import { stripMarkdown } from '~/utils/markdown' import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const emit = defineEmits(['close'])
name: 'SearchDropdown',
components: { Dropdown },
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const toggle = () => { const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const toggle = () => {
dropdown.value.toggle() dropdown.value.toggle()
} }
const onClose = () => emit('close') const onClose = () => emit('close')
const fetchResults = async (kw) => { const fetchResults = async (kw) => {
if (!kw) return [] if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`) const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return [] if (!res.ok) return []
@@ -76,58 +72,48 @@ export default {
postId: r.postId, postId: r.postId,
})) }))
return results.value return results.value
} }
const highlight = (text) => { const highlight = (text) => {
text = stripMarkdown(text) text = stripMarkdown(text)
if (!keyword.value) return text if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi') const reg = new RegExp(keyword.value, 'gi')
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`) const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
return res return res
} }
const iconMap = { const iconMap = {
user: 'fas fa-user', user: 'fas fa-user',
post: 'fas fa-file-alt', post: 'fas fa-file-alt',
comment: 'fas fa-comment', comment: 'fas fa-comment',
category: 'fas fa-folder', category: 'fas fa-folder',
tag: 'fas fa-hashtag', tag: 'fas fa-hashtag',
} }
watch(selected, (val) => { watch(selected, (val) => {
if (!val) return if (!val) return
const opt = results.value.find((r) => r.id === val) const opt = results.value.find((r) => r.id === val)
if (!opt) return if (!opt) return
if (opt.type === 'post' || opt.type === 'post_title') { if (opt.type === 'post' || opt.type === 'post_title') {
router.push(`/posts/${opt.id}`) navigateTo(`/posts/${opt.id}`, { replace: true })
} else if (opt.type === 'user') { } else if (opt.type === 'user') {
router.push(`/users/${opt.id}`) navigateTo(`/users/${opt.id}`, { replace: true })
} else if (opt.type === 'comment') { } else if (opt.type === 'comment') {
if (opt.postId) { if (opt.postId) {
router.push(`/posts/${opt.postId}#comment-${opt.id}`) navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
} }
} else if (opt.type === 'category') { } else if (opt.type === 'category') {
router.push({ path: '/', query: { category: opt.id } }) navigateTo({ path: '/', query: { category: opt.id } }, { replace: true })
} else if (opt.type === 'tag') { } else if (opt.type === 'tag') {
router.push({ path: '/', query: { tags: opt.id } }) navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
} }
selected.value = null selected.value = null
keyword.value = '' keyword.value = ''
}) })
return { defineExpose({
keyword,
selected,
fetchResults,
highlight,
iconMap,
isMobile,
dropdown,
onClose,
toggle, toggle,
} })
},
}
</script> </script>
<style scoped> <style scoped>
@@ -149,7 +135,7 @@ export default {
} }
.text-input { .text-input {
background-color: var(--menu-background-color); background-color: var(--app-menu-background-color);
color: var(--text-color); color: var(--text-color);
border: none; border: none;
outline: none; outline: none;

View File

@@ -28,42 +28,41 @@
</Dropdown> </Dropdown>
</template> </template>
<script> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const emit = defineEmits(['update:modelValue'])
name: 'TagSelect', const props = defineProps({
components: { Dropdown },
props: {
modelValue: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] },
creatable: { type: Boolean, default: false }, creatable: { type: Boolean, default: false },
options: { type: Array, default: () => [] }, options: { type: Array, default: () => [] },
}, })
emits: ['update:modelValue'],
setup(props, { emit }) {
const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
watch( const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options, () => props.options,
(val) => { (val) => {
providedTags.value = Array.isArray(val) ? [...val] : [] providedTags.value = Array.isArray(val) ? [...val] : []
}, },
) )
const mergedOptions = computed(() => { const mergedOptions = computed(() => {
const arr = [...providedTags.value, ...localTags.value] const arr = [...providedTags.value, ...localTags.value]
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i) return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
}) })
const isImageIcon = (icon) => { const isImageIcon = (icon) => {
if (!icon) return false if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/') return /^https?:\/\//.test(icon) || icon.startsWith('/')
} }
const buildTagsUrl = (kw = '') => { const buildTagsUrl = (kw = '') => {
const base = API_BASE_URL || (process.client ? window.location.origin : '') const base = API_BASE_URL || (process.client ? window.location.origin : '')
const url = new URL('/api/tags', base) const url = new URL('/api/tags', base)
@@ -71,9 +70,9 @@ export default {
url.searchParams.set('limit', '10') url.searchParams.set('limit', '10')
return url.toString() return url.toString()
} }
const fetchTags = async (kw = '') => { const fetchTags = async (kw = '') => {
const defaultOption = { id: 0, name: '无标签' } const defaultOption = { id: 0, name: '无标签' }
// 1) 先拼 URL自动兜底到 window.location.origin // 1) 先拼 URL自动兜底到 window.location.origin
@@ -91,11 +90,7 @@ export default {
// 3) 合并、去重、可创建 // 3) 合并、去重、可创建
let options = [...data, ...localTags.value] let options = [...data, ...localTags.value]
if ( if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
props.creatable &&
kw &&
!options.some((t) => t.name.toLowerCase() === kw.toLowerCase())
) {
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` }) options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
} }
@@ -103,9 +98,9 @@ export default {
// 4) 最终结果 // 4) 最终结果
return [defaultOption, ...options] return [defaultOption, ...options]
} }
const selected = computed({ const selected = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => { set: (v) => {
if (Array.isArray(v)) { if (Array.isArray(v)) {
@@ -131,11 +126,7 @@ export default {
} }
emit('update:modelValue', v) emit('update:modelValue', v)
}, },
}) })
return { fetchTags, selected, isImageIcon, mergedOptions }
},
}
</script> </script>
<style scoped> <style scoped>

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

@@ -11,20 +11,15 @@
</div> </div>
</template> </template>
<script> <script setup>
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
export default { defineProps({
name: 'UserList',
components: { BasePlaceholder },
props: {
users: { type: Array, default: () => [] }, users: { type: Array, default: () => [] },
}, })
methods: {
handleUserClick(user) { const handleUserClick = (user) => {
this.$router.push(`/users/${user.id}`) navigateTo(`/users/${user.id}`, { replace: true })
},
},
} }
</script> </script>

View File

@@ -1 +0,0 @@
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'

View File

@@ -1,11 +1,5 @@
export const API_BASE_URL = 'https://www.open-isle.com'
// export const API_BASE_URL = 'http://127.0.0.1:8081'
// export const API_BASE_URL = 'http://30.211.97.238:8081'
export const GOOGLE_CLIENT_ID =
'777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
export const DISCORD_CLIENT_ID = '1394985417044000779'
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
// 重新导出 toast 功能,使用 composable 方式
export { toast } from './composables/useToast' export { toast } from './composables/useToast'
export const API_DOMAIN = 'https://www.open-isle.com'
export const API_PORT = ''
export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN

View File

@@ -2,9 +2,20 @@ import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({ export default defineNuxtConfig({
ssr: true, ssr: true,
// Ensure Vditor styles load before our overrides in global.css runtimeConfig: {
css: ['vditor/dist/index.css', '~/assets/global.css'], public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
websiteBaseUrl: process.env.NUXT_PUBLIC_WEBSITE_BASE_URL || '',
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
},
},
// 确保 Vditor 样式在 global.css 覆盖前加载
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
app: { app: {
pageTransition: { name: 'page', mode: 'out-in' },
head: { head: {
script: [ script: [
{ {
@@ -16,7 +27,31 @@ export default defineNuxtConfig({
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light'); const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
} catch (e) {}
let themeColor = '#fff';
let themeStatus = 'default';
if (theme === 'dark') {
themeColor = '#333';
themeStatus = 'black-translucent';
} else {
themeColor = '#ffffff';
themeStatus = 'default';
}
const androidMeta = document.createElement('meta');
androidMeta.name = 'theme-color';
androidMeta.content = themeColor;
const iosMeta = document.createElement('meta');
iosMeta.name = 'apple-mobile-web-app-status-bar-style';
iosMeta.content = themeStatus;
document.head.appendChild(androidMeta);
document.head.appendChild(iosMeta);
} catch (e) {
console.warn('Theme initialization failed:', e);
}
})(); })();
`, `,
}, },
@@ -42,5 +77,35 @@ export default defineNuxtConfig({
}, },
], ],
}, },
baseURL: '/',
buildAssetsDir: '/_nuxt/',
},
vue: {
compilerOptions: {
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
},
},
vite: {
build: {
// increase warning limit and split large libraries into separate chunks
chunkSizeWarningLimit: 1024,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vditor')) {
return 'vditor'
}
if (id.includes('echarts')) {
return 'echarts'
}
if (id.includes('highlight.js')) {
return 'highlight'
}
}
},
},
},
},
}, },
}) })

View File

File diff suppressed because it is too large Load Diff

View File

@@ -41,8 +41,9 @@ import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import VChart from 'vue-echarts' import VChart from 'vue-echarts'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth' import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer]) use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])

View File

@@ -29,35 +29,28 @@
</div> </div>
</template> </template>
<script> <script setup>
import { API_BASE_URL } from '~/main'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue' import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const activities = ref([])
name: 'ActivityListPageView', const isLoadingActivities = ref(false)
components: { MilkTeaActivityComponent },
data() { onMounted(async () => {
return { isLoadingActivities.value = true
activities: [],
TimeManager,
isLoadingActivities: false,
}
},
async mounted() {
this.isLoadingActivities = true
try { try {
const res = await fetch(`${API_BASE_URL}/api/activities`) const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) { if (res.ok) {
this.activities = await res.json() activities.value = await res.json()
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
this.isLoadingActivities = false isLoadingActivities.value = false
} }
}, })
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -2,24 +2,20 @@
<CallbackPage /> <CallbackPage />
</template> </template>
<script> <script setup>
import CallbackPage from '~/components/CallbackPage.vue' import CallbackPage from '~/components/CallbackPage.vue'
import { discordExchange } from '~/utils/discord' import { discordExchange } from '~/utils/discord'
export default { onMounted(async () => {
name: 'DiscordCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const code = url.searchParams.get('code') const code = url.searchParams.get('code')
const state = url.searchParams.get('state') const state = url.searchParams.get('state')
const result = await discordExchange(code, state, '') const result = await discordExchange(code, state, '')
if (result.needReason) { if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token) navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else { } else {
this.$router.push('/') navigateTo('/', { replace: true })
} }
}, })
}
</script> </script>

View File

@@ -2,6 +2,7 @@
<div class="forgot-page"> <div class="forgot-page">
<div class="forgot-content"> <div class="forgot-content">
<div class="forgot-title">找回密码</div> <div class="forgot-title">找回密码</div>
<div v-if="step === 0" class="step-content"> <div v-if="step === 0" class="step-content">
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" /> <BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
<div v-if="emailError" class="error-message">{{ emailError }}</div> <div v-if="emailError" class="error-message">{{ emailError }}</div>
@@ -19,109 +20,110 @@
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div> <div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
<div class="primary-button disabled" v-else>提交中...</div> <div class="primary-button disabled" v-else>提交中...</div>
</div> </div>
<div class="hint-message">
<i class="fas fa-info-circle"></i>
使用 Google 注册的用户可使用对应的邮箱进行找回密码
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
export default { import { useRoute } from 'vue-router'
name: 'ForgotPasswordPageView',
components: { BaseInput }, const config = useRuntimeConfig()
data() { const API_BASE_URL = config.public.apiBaseUrl
return {
step: 0, const step = ref(0)
email: '', const email = ref('')
code: '', const code = ref('')
password: '', const password = ref('')
token: '', const token = ref('')
emailError: '', const emailError = ref('')
passwordError: '', const passwordError = ref('')
isSending: false, const isSending = ref(false)
isVerifying: false, const isVerifying = ref(false)
isResetting: false, const isResetting = ref(false)
const route = useRoute()
onMounted(() => {
if (route.query.email) {
email.value = decodeURIComponent(route.query.email)
} }
}, })
mounted() { const sendCode = async () => {
if (this.$route.query.email) { if (!email.value) {
this.email = decodeURIComponent(this.$route.query.email) emailError.value = '邮箱不能为空'
}
},
methods: {
async sendCode() {
if (!this.email) {
this.emailError = '邮箱不能为空'
return return
} }
try { try {
this.isSending = true isSending.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, { const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.email }), body: JSON.stringify({ email: email.value }),
}) })
this.isSending = false isSending.value = false
if (res.ok) { if (res.ok) {
toast.success('验证码已发送') toast.success('验证码已发送')
this.step = 1 step.value = 1
} else { } else {
toast.error('请填写已注册邮箱') toast.error('请填写已注册邮箱')
} }
} catch (e) { } catch (e) {
this.isSending = false isSending.value = false
toast.error('发送失败') toast.error('发送失败')
} }
}, }
async verifyCode() { const verifyCode = async () => {
try { try {
this.isVerifying = true isVerifying.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, { const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.email, code: this.code }), body: JSON.stringify({ email: email.value, code: code.value }),
}) })
this.isVerifying = false isVerifying.value = false
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
this.token = data.token token.value = data.token
this.step = 2 step.value = 2
} else { } else {
toast.error(data.error || '验证失败') toast.error(data.error || '验证失败')
} }
} catch (e) { } catch (e) {
this.isVerifying = false isVerifying.value = false
toast.error('验证失败') toast.error('验证失败')
} }
}, }
async resetPassword() { const resetPassword = async () => {
if (!this.password) { if (!password.value) {
this.passwordError = '密码不能为空' passwordError.value = '密码不能为空'
return return
} }
try { try {
this.isResetting = true isResetting.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, { const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: this.token, password: this.password }), body: JSON.stringify({ token: token.value, password: password.value }),
}) })
this.isResetting = false isResetting.value = false
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
toast.success('密码已重置') toast.success('密码已重置')
this.$router.push('/login') navigateTo('/login', { replace: true })
} else if (data.field === 'password') { } else if (data.field === 'password') {
this.passwordError = data.error passwordError.value = data.error
} else { } else {
toast.error(data.error || '重置失败') toast.error(data.error || '重置失败')
} }
} catch (e) { } catch (e) {
this.isResetting = false isResetting.value = false
toast.error('重置失败') toast.error('重置失败')
} }
},
},
} }
</script> </script>
@@ -143,6 +145,21 @@ export default {
font-size: 24px; font-size: 24px;
font-weight: bold; font-weight: bold;
} }
.forgot-content .hint-message {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
padding: 8px 0;
font-size: 13px;
color: var(--blockquote-text-color);
}
.hint-message i {
color: var(--primary-color);
font-size: 14px;
}
.step-content { .step-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -2,24 +2,20 @@
<CallbackPage /> <CallbackPage />
</template> </template>
<script> <script setup>
import CallbackPage from '~/components/CallbackPage.vue' import CallbackPage from '~/components/CallbackPage.vue'
import { githubExchange } from '~/utils/github' import { githubExchange } from '~/utils/github'
export default { onMounted(async () => {
name: 'GithubCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const code = url.searchParams.get('code') const code = url.searchParams.get('code')
const state = url.searchParams.get('state') const state = url.searchParams.get('state')
const result = await githubExchange(code, state, '') const result = await githubExchange(code, state, '')
if (result.needReason) { if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token) navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else { } else {
this.$router.push('/') navigateTo('/', { replace: true })
} }
}, })
}
</script> </script>

View File

@@ -2,29 +2,25 @@
<CallbackPage /> <CallbackPage />
</template> </template>
<script> <script setup>
import CallbackPage from '~/components/CallbackPage.vue' import CallbackPage from '~/components/CallbackPage.vue'
import { googleAuthWithToken } from '~/utils/google' import { googleAuthWithToken } from '~/utils/google'
export default { onMounted(async () => {
name: 'GoogleCallbackPageView',
components: { CallbackPage },
async mounted() {
const hash = new URLSearchParams(window.location.hash.substring(1)) const hash = new URLSearchParams(window.location.hash.substring(1))
const idToken = hash.get('id_token') const idToken = hash.get('id_token')
if (idToken) { if (idToken) {
await googleAuthWithToken( await googleAuthWithToken(
idToken, idToken,
() => { () => {
this.$router.push('/') navigateTo('/', { replace: true })
}, },
(token) => { (token) => {
this.$router.push('/signup-reason?token=' + token) navigateTo(`/signup-reason?token=${token}`, { replace: true })
}, },
) )
} else { } else {
this.$router.push('/login') navigateTo('/login', { replace: true })
} }
}, })
}
</script> </script>

View File

@@ -1,10 +1,7 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<div v-if="!isMobile" class="search-container"> <div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航</div> <div class="search-title">一切可能从此刻启航在此遇见灵感与共鸣</div>
<div class="search-subtitle">
愿你在此遇见灵感与共鸣若有疑惑欢迎发问亦可在知识的海洋中搜寻答案
</div>
<SearchDropdown /> <SearchDropdown />
</div> </div>
@@ -50,7 +47,7 @@
</div> </div>
</div> </div>
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container"> <div v-if="pendingFirst" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
@@ -60,7 +57,12 @@
</div> </div>
</div> </div>
<div class="article-item" v-for="article in articles" :key="article.id"> <div
v-if="!pendingFirst"
class="article-item"
v-for="article in articles"
:key="article.id"
>
<div class="article-main-container"> <div class="article-main-container">
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`"> <NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i> <i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
@@ -104,43 +106,26 @@
热门帖子功能开发中,敬请期待。 热门帖子功能开发中,敬请期待。
</div> </div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div> <div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading"> <div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup>
<script> import { computed, onMounted, ref, watch } from 'vue'
import { ref, watch } from 'vue' import ArticleCategory from '~/components/ArticleCategory.vue'
import { useRoute } from 'vue-router' import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import TagSelect from '~/components/TagSelect.vue'
import { getToken } from '~/utils/auth'
import { useScrollLoadMore } from '~/utils/loadMore' import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown' import { stripMarkdown } from '~/utils/markdown'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
import TimeManager from '~/utils/time'
import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
import TimeManager from '~/utils/time'
export default { useHead({
name: 'HomePageView',
components: {
CategorySelect,
TagSelect,
ArticleTags,
ArticleCategory,
SearchDropdown,
ClientOnly: () =>
import('vue').then((m) =>
m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))),
),
},
async setup() {
useHead({
title: 'OpenIsle - 全面开源的自由社区', title: 'OpenIsle - 全面开源的自由社区',
meta: [ meta: [
{ {
@@ -149,53 +134,81 @@ export default {
'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。', 'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
}, },
], ],
}) })
const route = useRoute()
const selectedCategory = ref('') const config = useRuntimeConfig()
if (route.query.category) { const API_BASE_URL = config.public.apiBaseUrl
const c = decodeURIComponent(route.query.category) const selectedCategory = ref('')
const selectedTags = ref([])
const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingMore = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopicCookie = useCookie('homeTab')
const selectedTopic = ref(
selectedTopicCookie.value
? selectedTopicCookie.value
: route.query.view === 'ranking'
? '排行榜'
: route.query.view === 'latest'
? '最新'
: '最新回复',
)
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
/** URL 参数 -> 本地筛选值 **/
const selectedCategorySet = (category) => {
const c = decodeURIComponent(category)
selectedCategory.value = isNaN(c) ? c : Number(c) selectedCategory.value = isNaN(c) ? c : Number(c)
} }
const selectedTags = ref([]) const selectedTagsSet = (tags) => {
if (route.query.tags) { const t = Array.isArray(tags) ? tags.join(',') : tags
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
selectedTags.value = t selectedTags.value = t
.split(',') .split(',')
.filter((v) => v) .filter((v) => v)
.map((v) => decodeURIComponent(v)) .map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v))) .map((v) => (isNaN(v) ? v : Number(v)))
}
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
onMounted(() => {
const { category, tags } = route.query
if (category) selectedCategorySet(category)
if (tags) selectedTagsSet(tags)
const saved = localStorage.getItem('homeTab')
if (saved) {
selectedTopic.value = saved
} }
})
const tagOptions = ref([]) /** 路由变更时同步筛选 **/
const categoryOptions = ref([]) watch(
const isLoadingPosts = ref(false) () => route.query,
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/]) (query) => {
const selectedTopic = ref( const category = query.category
route.query.view === 'ranking' const tags = query.tags
? '排行榜' category && selectedCategorySet(category)
: route.query.view === 'latest' tags && selectedTagsSet(tags)
? '最新' },
: '最新回复', )
)
const articles = ref([]) /** 选项加载(分类/标签名称回填) **/
const page = ref(0) const loadOptions = async () => {
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) { if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try { try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`) const res = await fetch(`${API_BASE_URL}/api/categories/`)
if (res.ok) { if (res.ok) categoryOptions.value = [await res.json()]
categoryOptions.value = [await res.json()] } catch {}
} }
} catch (e) {
/* ignore */
}
}
if (selectedTags.value.length) { if (selectedTags.value.length) {
const arr = [] const arr = []
for (const t of selectedTags.value) { for (const t of selectedTags.value) {
@@ -203,221 +216,164 @@ export default {
try { try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`) const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json()) if (r.ok) arr.push(await r.json())
} catch (e) { } catch {}
/* ignore */
}
} }
} }
tagOptions.value = arr tagOptions.value = arr
} }
}
const buildUrl = () => {
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildRankUrl = () => {
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildReplyUrl = () => {
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchRanking = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildRankUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchLatestReply = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildReplyUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.lastReplyAt || p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchContent = async (reset = false) => {
if (selectedTopic.value === '排行榜') {
await fetchRanking(reset)
} else if (selectedTopic.value === '最新回复') {
await fetchLatestReply(reset)
} else {
await fetchPosts(reset)
}
}
useScrollLoadMore(fetchContent)
watch([selectedCategory, selectedTags], () => {
fetchContent(true)
})
watch(selectedTopic, () => {
fetchContent(true)
})
const sanitizeDescription = (text) => stripMarkdown(text)
await Promise.all([loadOptions(), fetchContent()])
return {
topics,
selectedTopic,
articles,
sanitizeDescription,
isLoadingPosts,
selectedCategory,
selectedTags,
tagOptions,
categoryOptions,
isMobile,
}
},
} }
/** 列表 API 路径与查询参数 **/
const baseQuery = computed(() => ({
categoryId: selectedCategory.value || undefined,
tagIds: selectedTags.value.length ? selectedTags.value : undefined,
}))
const listApiPath = computed(() => {
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
return '/api/posts'
})
const buildUrl = ({ pageNo }) => {
const url = new URL(`${API_BASE_URL}${listApiPath.value}`)
url.searchParams.set('page', pageNo)
url.searchParams.set('pageSize', pageSize)
if (baseQuery.value.categoryId) url.searchParams.set('categoryId', baseQuery.value.categoryId)
if (baseQuery.value.tagIds)
for (const t of baseQuery.value.tagIds) url.searchParams.append('tagIds', t)
return url.toString()
}
const tokenHeader = computed(() => {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
})
/** —— 首屏数据托管SSR —— **/
const asyncKey = computed(() => [
'home:firstpage',
selectedTopic.value,
String(baseQuery.value.categoryId ?? ''),
JSON.stringify(baseQuery.value.tagIds ?? []),
])
const {
data: firstPage,
pending: pendingFirst,
refresh: refreshFirst,
} = await useAsyncData(
() => asyncKey.value.join('::'),
async () => {
const res = await $fetch(buildUrl({ pageNo: 0 }), { headers: tokenHeader.value })
const data = Array.isArray(res) ? res : []
return data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type,
}))
},
{
server: true,
default: () => [],
watch: [selectedTopic, baseQuery],
},
)
/** 首屏/筛选变更:重置分页并灌入 firstPage **/
watch(
firstPage,
(data) => {
page.value = 0
articles.value = [...(data || [])]
allLoaded.value = (data?.length || 0) < pageSize
},
{ immediate: true },
)
/** —— 滚动加载更多 —— **/
let inflight = null
const fetchNextPage = async () => {
if (allLoaded.value || pendingFirst.value || inflight) return
const nextPage = page.value + 1
isLoadingMore.value = true
inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
.then((res) => {
const data = Array.isArray(res) ? res : []
const mapped = data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type,
}))
articles.value.push(...mapped)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value = nextPage
}
})
.finally(() => {
inflight = null
isLoadingMore.value = false
})
}
/** 绑定滚动加载(避免挂载瞬间触发) **/
let initialReady = false
const loadMoreGuarded = async () => {
if (!initialReady) return
await fetchNextPage()
}
useScrollLoadMore(loadMoreGuarded)
watch(
articles,
() => {
if (!initialReady && articles.value.length) initialReady = true
},
{ immediate: true },
)
/** 切换分类/标签/TabuseAsyncData 已 watch这里只需确保 options 加载 **/
watch([selectedCategory, selectedTags], () => {
loadOptions()
})
watch(selectedTopic, (val) => {
// 仅当需要额外选项时加载
loadOptions()
selectedTopicCookie.value = val
if (process.client) {
localStorage.setItem('homeTab', val)
}
})
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
if (import.meta.server) {
await loadOptions()
}
onMounted(() => {
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
window.addEventListener('refresh-home', refreshFirst)
})
/** 其他工具函数 **/
const sanitizeDescription = (text) => stripMarkdown(text)
</script> </script>
<style scoped> <style scoped>
@@ -431,8 +387,8 @@ export default {
} }
.search-container { .search-container {
margin-top: 100px; margin-top: 32px;
padding: 20px; padding: 20px 20px 32px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -444,10 +400,6 @@ export default {
font-weight: bold; font-weight: bold;
} }
.search-subtitle {
font-size: 16px;
}
.loading-container { .loading-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -482,6 +434,7 @@ export default {
gap: 10px; gap: 10px;
width: 100%; width: 100%;
padding: 10px 0; padding: 10px 0;
backdrop-filter: var(--blur-10);
} }
.topic-item-container { .topic-item-container {
@@ -601,6 +554,7 @@ export default {
font-size: 14px; font-size: 14px;
color: gray; color: gray;
display: -webkit-box; display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
@@ -696,6 +650,7 @@ export default {
.header-item.activity { .header-item.activity {
width: 10%; width: 10%;
} }
.article-member-avatar-item:nth-child(n + 4) { .article-member-avatar-item:nth-child(n + 4) {
display: none; display: none;
} }

View File

@@ -27,6 +27,10 @@
>找回密码</a >找回密码</a
> >
</div> </div>
<div class="hint-message">
<i class="fas fa-info-circle"></i>
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
</div>
</div> </div>
</div> </div>
@@ -51,8 +55,8 @@
</div> </div>
</template> </template>
<script> <script setup>
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { setToken, loadCurrentUser } from '~/utils/auth' import { setToken, loadCurrentUser } from '~/utils/auth'
import { googleAuthorize } from '~/utils/google' import { googleAuthorize } from '~/utils/google'
import { githubAuthorize } from '~/utils/github' import { githubAuthorize } from '~/utils/github'
@@ -60,27 +64,19 @@ import { discordAuthorize } from '~/utils/discord'
import { twitterAuthorize } from '~/utils/twitter' import { twitterAuthorize } from '~/utils/twitter'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import { registerPush } from '~/utils/push' import { registerPush } from '~/utils/push'
export default { const config = useRuntimeConfig()
name: 'LoginPageView', const API_BASE_URL = config.public.apiBaseUrl
components: { BaseInput }, const username = ref('')
setup() { const password = ref('')
return { googleAuthorize } const isWaitingForLogin = ref(false)
},
data() { const submitLogin = async () => {
return {
username: '',
password: '',
isWaitingForLogin: false,
}
},
methods: {
async submitLogin() {
try { try {
this.isWaitingForLogin = true isWaitingForLogin.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/login`, { const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.username, password: this.password }), body: JSON.stringify({ username: username.value, password: password.value }),
}) })
const data = await res.json() const data = await res.json()
if (res.ok && data.token) { if (res.ok && data.token) {
@@ -88,35 +84,36 @@ export default {
await loadCurrentUser() await loadCurrentUser()
toast.success('登录成功') toast.success('登录成功')
registerPush() registerPush()
this.$router.push('/') await navigateTo('/', { replace: true })
} else if (data.reason_code === 'NOT_VERIFIED') { } else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码') toast.info('当前邮箱未验证,已经为您重新发送验证码')
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } }) await navigateTo(
{ path: '/signup', query: { verify: '1', u: username.value } },
{ replace: true },
)
} else if (data.reason_code === 'IS_APPROVING') { } else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册正在审批中, 请留意邮件') toast.info('您的注册正在审批中, 请留意邮件')
this.$router.push('/') await navigateTo('/', { replace: true })
} else if (data.reason_code === 'NOT_APPROVED') { } else if (data.reason_code === 'NOT_APPROVED') {
this.$router.push('/signup-reason?token=' + data.token) await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
} else { } else {
toast.error(data.error || '登录失败') toast.error(data.error || '登录失败')
} }
} catch (e) { } catch (e) {
toast.error('登录失败') toast.error('登录失败')
} finally { } finally {
this.isWaitingForLogin = false isWaitingForLogin.value = false
} }
}, }
loginWithGithub() { const loginWithGithub = () => {
githubAuthorize() githubAuthorize()
}, }
loginWithDiscord() { const loginWithDiscord = () => {
discordAuthorize() discordAuthorize()
}, }
loginWithTwitter() { const loginWithTwitter = () => {
twitterAuthorize() twitterAuthorize()
},
},
} }
</script> </script>
@@ -266,6 +263,11 @@ export default {
color: var(--primary-color); color: var(--primary-color);
} }
.hint-message {
font-size: 12px;
opacity: 0.7;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.login-page { .login-page {
flex-direction: column; flex-direction: column;

View File

@@ -185,6 +185,32 @@
</router-link> </router-link>
</NotificationContainer> </NotificationContainer>
</template> </template>
<template v-else-if="item.type === 'LOTTERY_WIN'">
<NotificationContainer :item="item" :markRead="markRead">
恭喜你在抽奖贴
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
中获奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_DRAW'">
<NotificationContainer :item="item" :markRead="markRead">
您的抽奖贴
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
已开奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'"> <template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead"> <NotificationContainer :item="item" :markRead="markRead">
您关注的帖子 您关注的帖子
@@ -478,247 +504,40 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted, computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { API_BASE_URL } from '~/main'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import NotificationContainer from '~/components/NotificationContainer.vue' import NotificationContainer from '~/components/NotificationContainer.vue'
import { getToken, authState } from '~/utils/auth'
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
import { toast } from '~/main' import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { stripMarkdownLength } from '~/utils/markdown' import { stripMarkdownLength } from '~/utils/markdown'
import {
fetchNotifications,
fetchUnreadCount,
isLoadingMessage,
markRead,
notifications,
markAllRead,
} from '~/utils/notification'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { reactionEmojiMap } from '~/utils/reactions'
export default { const config = useRuntimeConfig()
name: 'MessagePageView', const API_BASE_URL = config.public.apiBaseUrl
components: { BaseTimeline, BasePlaceholder, NotificationContainer }, const route = useRoute()
setup() { const selectedTab = ref(
const router = useRouter()
const route = useRoute()
const notifications = ref([])
const isLoadingMessage = ref(false)
const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread', ['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
) )
const notificationPrefs = ref([]) const notificationPrefs = ref([])
const filteredNotifications = computed(() => const filteredNotifications = computed(() =>
selectedTab.value === 'all' selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
? notifications.value )
: notifications.value.filter((n) => !n.read),
)
const markRead = async (id) => { const fetchPrefs = async () => {
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',
MENTION: 'fas fa-at',
}
const fetchNotifications = async () => {
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
isLoadingMessage.value = true
notifications.value = []
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
isLoadingMessage.value = false
if (!res.ok) {
toast.error('获取通知失败')
return
}
const data = await res.json()
for (const n of data) {
if (n.type === 'COMMENT_REPLY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
},
})
} else if (n.type === 'REACTION') {
notifications.value.push({
...n,
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
},
})
} else if (n.type === 'POST_VIEWED') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
},
})
} else if (n.type === 'USER_ACTIVITY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
},
})
} else if (n.type === 'MENTION') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
},
})
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
},
})
} else if (n.type === 'FOLLOWED_POST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_REVIEW_REQUEST') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {},
})
} else {
notifications.value.push({
...n,
icon: iconMap[n.type],
})
}
}
} catch (e) {
console.error(e)
}
}
const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences() notificationPrefs.value = await fetchNotificationPreferences()
} }
const togglePref = async (pref) => { const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled) const ok = await updateNotificationPreference(pref.type, !pref.enabled)
if (ok) { if (ok) {
pref.enabled = !pref.enabled pref.enabled = !pref.enabled
@@ -727,9 +546,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const approve = async (id, nid) => { const approve = async (id, nid) => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, { const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
@@ -742,9 +561,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const reject = async (id, nid) => { const reject = async (id, nid) => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, { const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
@@ -757,9 +576,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const formatType = (t) => { const formatType = (t) => {
switch (t) { switch (t) {
case 'POST_VIEWED': case 'POST_VIEWED':
return '帖子被查看' return '帖子被查看'
@@ -791,34 +610,19 @@ export default {
return '有人申请注册' return '有人申请注册'
case 'ACTIVITY_REDEEM': case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶' return '有人申请兑换奶茶'
case 'LOTTERY_WIN':
return '抽奖中奖了'
case 'LOTTERY_DRAW':
return '抽奖已开奖'
default: default:
return t return t
} }
} }
onMounted(() => { onActivated(() => {
fetchNotifications() fetchNotifications()
fetchPrefs() fetchPrefs()
}) })
return {
notifications,
formatType,
isLoadingMessage,
stripMarkdownLength,
markRead,
approve,
reject,
TimeManager,
selectedTab,
filteredNotifications,
markAllRead,
authState,
notificationPrefs,
togglePref,
}
},
}
</script> </script>
<style scoped> <style scoped>
@@ -832,6 +636,8 @@ export default {
.message-page { .message-page {
background-color: var(--background-color); background-color: var(--background-color);
overflow-x: hidden; overflow-x: hidden;
height: calc(100vh - var(--header-height));
overflow-y: auto;
} }
.message-page-header { .message-page-header {
@@ -843,6 +649,7 @@ export default {
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
backdrop-filter: var(--blur-10);
} }
.message-page-header-right { .message-page-header-right {

View File

@@ -77,7 +77,7 @@
</div> </div>
</template> </template>
<script> <script setup>
import 'flatpickr/dist/flatpickr.css' import 'flatpickr/dist/flatpickr.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import FlatPickr from 'vue-flatpickr-component' import FlatPickr from 'vue-flatpickr-component'
@@ -88,41 +88,31 @@ import LoginOverlay from '~/components/LoginOverlay.vue'
import PostEditor from '~/components/PostEditor.vue' import PostEditor from '~/components/PostEditor.vue'
import PostTypeSelect from '~/components/PostTypeSelect.vue' import PostTypeSelect from '~/components/PostTypeSelect.vue'
import TagSelect from '~/components/TagSelect.vue' import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth' import { authState, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const title = ref('')
name: 'NewPostPageView', const content = ref('')
components: { const selectedCategory = ref('')
PostEditor, const selectedTags = ref([])
CategorySelect, const postType = ref('NORMAL')
TagSelect, const prizeIcon = ref('')
LoginOverlay, const prizeIconFile = ref(null)
PostTypeSelect, const tempPrizeIcon = ref('')
AvatarCropper, const showPrizeCropper = ref(false)
FlatPickr, const prizeName = ref('')
}, const prizeCount = ref(1)
setup() { const prizeDescription = ref('')
const title = ref('') const endTime = ref(null)
const content = ref('') const startTime = ref(null)
const selectedCategory = ref('') const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const selectedTags = ref([]) const isWaitingPosting = ref(false)
const postType = ref('NORMAL') const isAiLoading = ref(false)
const prizeIcon = ref('') const isLogin = computed(() => authState.loggedIn)
const prizeIconFile = ref(null)
const tempPrizeIcon = ref('')
const showPrizeCropper = ref(false)
const prizeName = ref('')
const prizeCount = ref(1)
const prizeDescription = ref('')
const endTime = ref(null)
const startTime = ref(null)
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const onPrizeIconChange = (e) => { const onPrizeIconChange = (e) => {
const file = e.target.files[0] const file = e.target.files[0]
if (file) { if (file) {
const reader = new FileReader() const reader = new FileReader()
@@ -132,18 +122,18 @@ export default {
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
} }
const onPrizeCropped = ({ file, url }) => { const onPrizeCropped = ({ file, url }) => {
prizeIconFile.value = file prizeIconFile.value = file
prizeIcon.value = url prizeIcon.value = url
} }
watch(prizeCount, (val) => { watch(prizeCount, (val) => {
if (!val || val < 1) prizeCount.value = 1 if (!val || val < 1) prizeCount.value = 1
}) })
const loadDraft = async () => { const loadDraft = async () => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
try { try {
@@ -162,11 +152,11 @@ export default {
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
} }
onMounted(loadDraft) onMounted(loadDraft)
const clearPost = async () => { const clearPost = async () => {
title.value = '' title.value = ''
content.value = '' content.value = ''
selectedCategory.value = '' selectedCategory.value = ''
@@ -196,9 +186,9 @@ export default {
toast.error('云端草稿清空失败, 请稍后重试') toast.error('云端草稿清空失败, 请稍后重试')
} }
} }
} }
const saveDraft = async () => { const saveDraft = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -227,8 +217,8 @@ export default {
} catch (e) { } catch (e) {
toast.error('保存失败') toast.error('保存失败')
} }
} }
const ensureTags = async (token) => { const ensureTags = async (token) => {
for (let i = 0; i < selectedTags.value.length; i++) { for (let i = 0; i < selectedTags.value.length; i++) {
const t = selectedTags.value[i] const t = selectedTags.value[i]
if (typeof t === 'string' && t.startsWith('__new__:')) { if (typeof t === 'string' && t.startsWith('__new__:')) {
@@ -257,9 +247,9 @@ export default {
} }
} }
} }
} }
const aiGenerate = async () => { const aiGenerate = async () => {
if (!content.value.trim()) { if (!content.value.trim()) {
toast.error('内容为空,无法优化') toast.error('内容为空,无法优化')
return return
@@ -289,9 +279,9 @@ export default {
} finally { } finally {
isAiLoading.value = false isAiLoading.value = false
} }
} }
const submitPost = async () => { const submitPost = async () => {
if (!title.value.trim()) { if (!title.value.trim()) {
toast.error('标题不能为空') toast.error('标题不能为空')
return return
@@ -391,32 +381,6 @@ export default {
} finally { } finally {
isWaitingPosting.value = false isWaitingPosting.value = false
} }
}
return {
title,
content,
selectedCategory,
selectedTags,
postType,
prizeIcon,
prizeCount,
endTime,
submitPost,
saveDraft,
clearPost,
isWaitingPosting,
aiGenerate,
isAiLoading,
isLogin,
onPrizeIconChange,
onPrizeCropped,
showPrizeCropper,
tempPrizeIcon,
dateConfig,
prizeName,
prizeDescription,
}
},
} }
</script> </script>

View File

@@ -0,0 +1,199 @@
<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="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" @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 pointRules = [
'发帖:每天前两次,每次 30 积分',
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
'帖子被点赞:每次 10 积分',
'评论被点赞:每次 10 积分',
]
const goods = ref([])
const dialogVisible = ref(false)
const contact = ref('')
const loading = ref(false)
const selectedGood = ref(null)
onMounted(async () => {
if (authState.loggedIn) {
const user = await fetchCurrentUser()
point.value = user ? user.point : null
}
await loadGoods()
})
const loadGoods = async () => {
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
if (res.ok) {
goods.value = await res.json()
}
}
const openRedeem = (good) => {
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;
}
.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);
}
.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

@@ -35,33 +35,30 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import PostEditor from '~/components/PostEditor.vue' import PostEditor from '~/components/PostEditor.vue'
import CategorySelect from '~/components/CategorySelect.vue' import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.vue' import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth' import { getToken, authState } from '~/utils/auth'
import LoginOverlay from '~/components/LoginOverlay.vue' import LoginOverlay from '~/components/LoginOverlay.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const title = ref('')
name: 'EditPostPageView', const content = ref('')
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay }, const selectedCategory = ref('')
setup() { const selectedTags = ref([])
const title = ref('') const isWaitingPosting = ref(false)
const content = ref('') const isAiLoading = ref(false)
const selectedCategory = ref('') const isLogin = computed(() => authState.loggedIn)
const selectedTags = ref([])
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const route = useRoute() const route = useRoute()
const router = useRouter() const postId = route.params.id
const postId = route.params.id
const loadPost = async () => { const loadPost = async () => {
try { try {
const token = getToken() const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, { const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
@@ -77,18 +74,18 @@ export default {
} catch (e) { } catch (e) {
toast.error('加载失败') toast.error('加载失败')
} }
} }
onMounted(loadPost) onMounted(loadPost)
const clearPost = () => { const clearPost = () => {
title.value = '' title.value = ''
content.value = '' content.value = ''
selectedCategory.value = '' selectedCategory.value = ''
selectedTags.value = [] selectedTags.value = []
} }
const ensureTags = async (token) => { const ensureTags = async (token) => {
for (let i = 0; i < selectedTags.value.length; i++) { for (let i = 0; i < selectedTags.value.length; i++) {
const t = selectedTags.value[i] const t = selectedTags.value[i]
if (typeof t === 'string' && t.startsWith('__new__:')) { if (typeof t === 'string' && t.startsWith('__new__:')) {
@@ -117,9 +114,9 @@ export default {
} }
} }
} }
} }
const aiGenerate = async () => { const aiGenerate = async () => {
if (!content.value.trim()) { if (!content.value.trim()) {
toast.error('内容为空,无法优化') toast.error('内容为空,无法优化')
return return
@@ -149,9 +146,9 @@ export default {
} finally { } finally {
isAiLoading.value = false isAiLoading.value = false
} }
} }
const submitPost = async () => { const submitPost = async () => {
if (!title.value.trim()) { if (!title.value.trim()) {
toast.error('标题不能为空') toast.error('标题不能为空')
return return
@@ -197,24 +194,9 @@ export default {
} finally { } finally {
isWaitingPosting.value = false isWaitingPosting.value = false
} }
} }
const cancelEdit = () => { const cancelEdit = () => {
router.push(`/posts/${postId}`) navigateTo(`/posts/${postId}`, { replace: true })
}
return {
title,
content,
selectedCategory,
selectedTags,
submitPost,
clearPost,
cancelEdit,
isWaitingPosting,
aiGenerate,
isAiLoading,
isLogin,
}
},
} }
</script> </script>

View File

@@ -195,6 +195,7 @@
:comment="item" :comment="item"
:level="0" :level="0"
:default-show-replies="item.openReplies" :default-show-replies="item.openReplies"
:post-author-id="author.id"
@deleted="onCommentDeleted" @deleted="onCommentDeleted"
/> />
</template> </template>
@@ -230,8 +231,8 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox' import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import CommentItem from '~/components/CommentItem.vue' import CommentItem from '~/components/CommentItem.vue'
@@ -243,58 +244,45 @@ import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue' import DropdownMenu from '~/components/DropdownMenu.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown' import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal' import { getMedalTitle } from '~/utils/medal'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth' import { getToken, authState } from '~/utils/auth'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components'
export default { const config = useRuntimeConfig()
name: 'PostPageView', const API_BASE_URL = config.public.apiBaseUrl
components: {
CommentItem,
CommentEditor,
BaseTimeline,
ArticleTags,
ArticleCategory,
ReactionsGroup,
DropdownMenu,
VueEasyLightbox,
Dropdown,
},
async setup() {
const route = useRoute()
const postId = route.params.id
const router = useRouter()
const title = ref('') const route = useRoute()
const author = ref('') const postId = route.params.id
const postContent = ref('') const router = useRouter()
const category = ref('')
const tags = ref([])
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([])
const mainContainer = ref(null)
const currentIndex = ref(1)
const subscribed = ref(false)
const commentSort = ref('NEWEST')
const isFetchingComments = ref(false)
const isMobile = useIsMobile()
const headerHeight = process.client const title = ref('')
? parseFloat( const author = ref('')
getComputedStyle(document.documentElement).getPropertyValue('--header-height'), const postContent = ref('')
) || 0 const category = ref('')
const tags = ref([])
const postReactions = ref([])
const comments = ref([])
const status = ref('PUBLISHED')
const pinnedAt = ref(null)
const isWaitingPostingComment = ref(false)
const postTime = ref('')
const postItems = ref([])
const mainContainer = ref(null)
const currentIndex = ref(1)
const subscribed = ref(false)
const commentSort = ref('NEWEST')
const isFetchingComments = ref(false)
const isMobile = useIsMobile()
const headerHeight = process.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
: 0 : 0
useHead(() => ({ useHead(() => ({
title: title.value ? `OpenIsle - ${title.value}` : 'OpenIsle', title: title.value ? `OpenIsle - ${title.value}` : 'OpenIsle',
meta: [ meta: [
{ {
@@ -302,35 +290,35 @@ export default {
content: stripMarkdownLength(postContent.value, 400), content: stripMarkdownLength(postContent.value, 400),
}, },
], ],
})) }))
if (process.client) { if (process.client) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex) window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer) if (countdownTimer) clearInterval(countdownTimer)
}) })
} }
const lightboxVisible = ref(false) const lightboxVisible = ref(false)
const lightboxIndex = ref(0) const lightboxIndex = ref(0)
const lightboxImgs = ref([]) const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn) const loggedIn = computed(() => authState.loggedIn)
const isAdmin = computed(() => authState.role === 'ADMIN') const isAdmin = computed(() => authState.role === 'ADMIN')
const isAuthor = computed(() => authState.username === author.value.username) const isAuthor = computed(() => authState.username === author.value.username)
const lottery = ref(null) const lottery = ref(null)
const countdown = ref('00:00:00') const countdown = ref('00:00:00')
let countdownTimer = null let countdownTimer = null
const lotteryParticipants = computed(() => lottery.value?.participants || []) const lotteryParticipants = computed(() => lottery.value?.participants || [])
const lotteryWinners = computed(() => lottery.value?.winners || []) const lotteryWinners = computed(() => lottery.value?.winners || [])
const lotteryEnded = computed(() => { const lotteryEnded = computed(() => {
if (!lottery.value || !lottery.value.endTime) return false if (!lottery.value || !lottery.value.endTime) return false
return new Date(lottery.value.endTime).getTime() <= Date.now() return new Date(lottery.value.endTime).getTime() <= Date.now()
}) })
const hasJoined = computed(() => { const hasJoined = computed(() => {
if (!loggedIn.value) return false if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId)) return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
}) })
const updateCountdown = () => { const updateCountdown = () => {
if (!lottery.value || !lottery.value.endTime) { if (!lottery.value || !lottery.value.endTime) {
countdown.value = '00:00:00' countdown.value = '00:00:00'
return return
@@ -348,15 +336,15 @@ export default {
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0') const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0') const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}` countdown.value = `${h}:${m}:${s}`
} }
const startCountdown = () => { const startCountdown = () => {
if (!process.client) return if (!process.client) return
if (countdownTimer) clearInterval(countdownTimer) if (countdownTimer) clearInterval(countdownTimer)
updateCountdown() updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000) countdownTimer = setInterval(updateCountdown, 1000)
} }
const gotoUser = (id) => router.push(`/users/${id}`) const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const articleMenuItems = computed(() => { const articleMenuItems = computed(() => {
const items = [] const items = []
if (isAuthor.value || isAdmin.value) { if (isAuthor.value || isAdmin.value) {
items.push({ text: '编辑文章', onClick: () => editPost() }) items.push({ text: '编辑文章', onClick: () => editPost() })
@@ -374,9 +362,9 @@ export default {
items.push({ text: '驳回', color: 'red', onClick: () => rejectPost() }) items.push({ text: '驳回', color: 'red', onClick: () => rejectPost() })
} }
return items return items
}) })
const gatherPostItems = () => { const gatherPostItems = () => {
const items = [] const items = []
if (mainContainer.value) { if (mainContainer.value) {
const main = mainContainer.value.querySelector('.info-content-container') const main = mainContainer.value.querySelector('.info-content-container')
@@ -392,9 +380,9 @@ export default {
items.sort((a, b) => a.top - b.top) items.sort((a, b) => a.top - b.top)
postItems.value = items.map((i) => i.el) postItems.value = items.map((i) => i.el)
} }
} }
const mapComment = (c, parentUserName = '', level = 0) => ({ const mapComment = (c, parentUserName = '', level = 0) => ({
id: c.id, id: c.id,
userName: c.author.username, userName: c.author.username,
medal: c.author.displayMedal, medal: c.author.displayMedal,
@@ -403,18 +391,19 @@ export default {
avatar: c.author.avatar, avatar: c.author.avatar,
text: c.content, text: c.content,
reactions: c.reactions || [], reactions: c.reactions || [],
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)), reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
openReplies: level === 0, openReplies: level === 0,
src: c.author.avatar, src: c.author.avatar,
iconClick: () => router.push(`/users/${c.author.id}`), iconClick: () => navigateTo(`/users/${c.author.id}`, { replace: true }),
parentUserName: parentUserName, parentUserName: parentUserName,
}) })
const getTop = (el) => { const getTop = (el) => {
return el.getBoundingClientRect().top + window.scrollY return el.getBoundingClientRect().top + window.scrollY
} }
const findCommentPath = (id, list) => { const findCommentPath = (id, list) => {
for (const item of list) { for (const item of list) {
if (item.id === Number(id) || item.id === id) { if (item.id === Number(id) || item.id === id) {
return [item] return [item]
@@ -425,17 +414,17 @@ export default {
} }
} }
return null return null
} }
const expandCommentPath = (id) => { const expandCommentPath = (id) => {
const path = findCommentPath(id, comments.value) const path = findCommentPath(id, comments.value)
if (!path) return if (!path) return
for (let i = 0; i < path.length - 1; i++) { for (let i = 0; i < path.length - 1; i++) {
path[i].openReplies = true path[i].openReplies = true
} }
} }
const removeCommentFromList = (id, list) => { const removeCommentFromList = (id, list) => {
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const item = list[i] const item = list[i]
if (item.id === id) { if (item.id === id) {
@@ -447,9 +436,9 @@ export default {
} }
} }
return false return false
} }
const handleContentClick = (e) => { const handleContentClick = (e) => {
handleMarkdownClick(e) handleMarkdownClick(e)
if (e.target.tagName === 'IMG') { if (e.target.tagName === 'IMG') {
const container = e.target.parentNode const container = e.target.parentNode
@@ -458,28 +447,30 @@ export default {
lightboxIndex.value = imgs.indexOf(e.target.src) lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true lightboxVisible.value = true
} }
} }
const onCommentDeleted = (id) => { const onCommentDeleted = (id) => {
removeCommentFromList(Number(id), comments.value) removeCommentFromList(Number(id), comments.value)
fetchComments() fetchComments()
} }
const fetchPost = async () => { const {
try { data: postData,
isWaitingFetchingPost.value = true pending: pendingPost,
const token = getToken() error: postError,
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, { refresh: refreshPost,
headers: { Authorization: token ? `Bearer ${token}` : '' }, } = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
}) server: true,
isWaitingFetchingPost.value = false lazy: false,
if (!res.ok) { })
if (res.status === 404 && process.client) {
router.replace('/404') // 用 pendingPost 驱动现有 UI替代 isWaitingFetchingPost 手控)
} const isWaitingFetchingPost = computed(() => pendingPost.value)
return
} // 同步到现有的响应式字段
const data = await res.json() watchEffect(() => {
const data = postData.value
if (!data) return
postContent.value = data.content postContent.value = data.content
author.value = data.author author.value = data.author
title.value = data.title title.value = data.title
@@ -492,33 +483,34 @@ export default {
postTime.value = TimeManager.format(data.createdAt) postTime.value = TimeManager.format(data.createdAt)
lottery.value = data.lottery || null lottery.value = data.lottery || null
if (lottery.value && lottery.value.endTime) startCountdown() if (lottery.value && lottery.value.endTime) startCountdown()
await nextTick() })
} catch (e) {
console.error(e)
}
}
const totalPosts = computed(() => comments.value.length + 1) // 404 客户端跳转
const lastReplyTime = computed(() => // if (postError.value?.statusCode === 404 && process.client) {
// router.replace('/404')
// }
const totalPosts = computed(() => comments.value.length + 1)
const lastReplyTime = computed(() =>
comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value, comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value,
) )
const firstReplyTime = computed(() => const firstReplyTime = computed(() =>
comments.value.length ? comments.value[0].time : postTime.value, comments.value.length ? comments.value[0].time : postTime.value,
) )
const scrollerTopTime = computed(() => const scrollerTopTime = computed(() =>
commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value, commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value,
) )
watch( watch(
() => comments.value.length, () => comments.value.length,
async () => { async () => {
await nextTick() await nextTick()
gatherPostItems() gatherPostItems()
updateCurrentIndex() updateCurrentIndex()
}, },
) )
const updateCurrentIndex = () => { const updateCurrentIndex = () => {
const scrollTop = window.scrollY const scrollTop = window.scrollY
for (let i = 0; i < postItems.value.length; i++) { for (let i = 0; i < postItems.value.length; i++) {
@@ -531,9 +523,9 @@ export default {
break break
} }
} }
} }
const onSliderInput = (e) => { const onSliderInput = (e) => {
const index = Number(e.target.value) const index = Number(e.target.value)
currentIndex.value = index currentIndex.value = index
const target = postItems.value[index - 1] const target = postItems.value[index - 1]
@@ -541,9 +533,9 @@ export default {
const top = getTop(target) - headerHeight - 20 // 20 for beauty const top = getTop(target) - headerHeight - 20 // 20 for beauty
window.scrollTo({ top, behavior: 'auto' }) window.scrollTo({ top, behavior: 'auto' })
} }
} }
const postComment = async (parentUserName, text, clear) => { const postComment = async (parentUserName, text, clear) => {
if (!text.trim()) return if (!text.trim()) return
console.debug('Posting comment', { postId, text }) console.debug('Posting comment', { postId, text })
isWaitingPostingComment.value = true isWaitingPostingComment.value = true
@@ -581,15 +573,15 @@ export default {
} finally { } finally {
isWaitingPostingComment.value = false isWaitingPostingComment.value = false
} }
} }
const copyPostLink = () => { const copyPostLink = () => {
navigator.clipboard.writeText(location.href.split('#')[0]).then(() => { navigator.clipboard.writeText(location.href.split('#')[0]).then(() => {
toast.success('已复制') toast.success('已复制')
}) })
} }
const subscribePost = async () => { const subscribePost = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -605,9 +597,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const approvePost = async () => { const approvePost = async () => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/approve`, { const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/approve`, {
@@ -617,12 +609,13 @@ export default {
if (res.ok) { if (res.ok) {
status.value = 'PUBLISHED' status.value = 'PUBLISHED'
toast.success('已通过审核') toast.success('已通过审核')
await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const pinPost = async () => { const pinPost = async () => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, { const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, {
@@ -630,14 +623,14 @@ export default {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
if (res.ok) { if (res.ok) {
pinnedAt.value = new Date().toISOString()
toast.success('已置顶') toast.success('已置顶')
await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const unpinPost = async () => { const unpinPost = async () => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, { const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, {
@@ -645,18 +638,18 @@ export default {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
if (res.ok) { if (res.ok) {
pinnedAt.value = null
toast.success('已取消置顶') toast.success('已取消置顶')
await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const editPost = () => { const editPost = () => {
router.push(`/posts/${postId}/edit`) navigateTo(`/posts/${postId}/edit`, { replace: true })
} }
const deletePost = async () => { const deletePost = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -668,13 +661,13 @@ export default {
}) })
if (res.ok) { if (res.ok) {
toast.success('已删除') toast.success('已删除')
router.push('/') navigateTo('/', { replace: true })
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const rejectPost = async () => { const rejectPost = async () => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/reject`, { const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/reject`, {
@@ -684,11 +677,12 @@ export default {
if (res.ok) { if (res.ok) {
status.value = 'REJECTED' status.value = 'REJECTED'
toast.success('已驳回') toast.success('已驳回')
await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const unsubscribePost = async () => { const unsubscribePost = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -705,9 +699,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const joinLottery = async () => { const joinLottery = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -719,21 +713,21 @@ export default {
}) })
if (res.ok) { if (res.ok) {
toast.success('已参与抽奖') toast.success('已参与抽奖')
await fetchPost() await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const fetchCommentSorts = () => { const fetchCommentSorts = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' }, { id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
{ id: 'OLDEST', name: '最旧', icon: 'fas fa-hourglass-start' }, { id: 'OLDEST', name: '最旧', icon: 'fas fa-hourglass-start' },
// { id: 'MOST_INTERACTIONS', name: '最多互动', icon: 'fas fa-fire' } // { id: 'MOST_INTERACTIONS', name: '最多互动', icon: 'fas fa-fire' }
]) ])
} }
const fetchComments = async () => { const fetchComments = async () => {
isFetchingComments.value = true isFetchingComments.value = true
console.debug('Fetching comments', { postId, sort: commentSort.value }) console.debug('Fetching comments', { postId, sort: commentSort.value })
try { try {
@@ -758,11 +752,11 @@ export default {
} finally { } finally {
isFetchingComments.value = false isFetchingComments.value = false
} }
} }
watch(commentSort, fetchComments) watch(commentSort, fetchComments)
const jumpToHashComment = async () => { const jumpToHashComment = async () => {
const hash = location.hash const hash = location.hash
if (hash.startsWith('#comment-')) { if (hash.startsWith('#comment-')) {
const id = hash.substring('#comment-'.length) const id = hash.substring('#comment-'.length)
@@ -775,13 +769,13 @@ export default {
setTimeout(() => el.classList.remove('comment-highlight'), 4000) setTimeout(() => el.classList.remove('comment-highlight'), 4000)
} }
} }
} }
const gotoProfile = () => { const gotoProfile = () => {
router.push(`/users/${author.value.id}`) navigateTo(`/users/${author.value.id}`, { replace: true })
} }
onMounted(async () => { onMounted(async () => {
await fetchComments() await fetchComments()
const hash = location.hash const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
@@ -789,70 +783,9 @@ export default {
updateCurrentIndex() updateCurrentIndex()
window.addEventListener('scroll', updateCurrentIndex) window.addEventListener('scroll', updateCurrentIndex)
jumpToHashComment() jumpToHashComment()
}) })
await fetchPost()
return {
postContent,
author,
title,
category,
tags,
comments,
postTime,
scrollerTopTime,
lastReplyTime,
postItems,
mainContainer,
currentIndex,
totalPosts,
postReactions,
articleMenuItems,
postId,
postComment,
onSliderInput,
copyPostLink,
subscribePost,
unsubscribePost,
joinLottery,
renderMarkdown,
isWaitingFetchingPost,
isWaitingPostingComment,
gotoProfile,
gotoUser,
subscribed,
loggedIn,
isAuthor,
status,
isAdmin,
approvePost,
editPost,
onCommentDeleted,
deletePost,
pinPost,
unpinPost,
rejectPost,
lightboxVisible,
lightboxIndex,
lightboxImgs,
handleContentClick,
isMobile,
pinnedAt,
commentSort,
fetchCommentSorts,
isFetchingComments,
getMedalTitle,
lottery,
countdown,
lotteryParticipants,
lotteryWinners,
lotteryEnded,
hasJoined,
}
},
}
</script> </script>
<style> <style>
.post-page-container { .post-page-container {
background-color: var(--background-color); background-color: var(--background-color);
@@ -934,6 +867,7 @@ export default {
direction: ltr; direction: ltr;
height: 300px; height: 300px;
width: 2px; width: 2px;
appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
background: transparent; background: transparent;
} }

View File

@@ -36,6 +36,13 @@
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." /> <BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
<div class="setting-description">自我介绍会出现在你的个人主页可以简要介绍自己</div> <div class="setting-description">自我介绍会出现在你的个人主页可以简要介绍自己</div>
</div> </div>
<div class="form-row switch-row">
<div class="setting-title">毛玻璃效果</div>
<label class="switch">
<input type="checkbox" v-model="frosted" />
<span class="slider"></span>
</label>
</div>
</div> </div>
<div v-if="role === 'ADMIN'" class="admin-section"> <div v-if="role === 'ADMIN'" class="admin-section">
<h3>管理员设置</h3> <h3>管理员设置</h3>
@@ -64,95 +71,96 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted, watch } from 'vue'
import AvatarCropper from '~/components/AvatarCropper.vue' import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth' import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
export default { import { frostedState, setFrosted } from '~/utils/frosted'
name: 'SettingsPageView', const config = useRuntimeConfig()
components: { BaseInput, Dropdown, AvatarCropper }, const API_BASE_URL = config.public.apiBaseUrl
data() { const username = ref('')
return { const introduction = ref('')
username: '', const usernameError = ref('')
introduction: '', const avatar = ref('')
usernameError: '', const avatarFile = ref(null)
avatar: '', const tempAvatar = ref('')
avatarFile: null, const showCropper = ref(false)
tempAvatar: '', const role = ref('')
showCropper: false, const publishMode = ref('DIRECT')
role: '', const passwordStrength = ref('LOW')
publishMode: 'DIRECT', const aiFormatLimit = ref(3)
passwordStrength: 'LOW', const registerMode = ref('DIRECT')
aiFormatLimit: 3, const isLoadingPage = ref(false)
registerMode: 'DIRECT', const isSaving = ref(false)
isLoadingPage: false, const frosted = ref(true)
isSaving: false,
} onMounted(async () => {
}, isLoadingPage.value = true
async mounted() {
this.isLoadingPage = true
const user = await fetchCurrentUser() const user = await fetchCurrentUser()
if (user) { if (user) {
this.username = user.username username.value = user.username
this.introduction = user.introduction || '' introduction.value = user.introduction || ''
this.avatar = user.avatar avatar.value = user.avatar
this.role = user.role role.value = user.role
if (this.role === 'ADMIN') { if (role.value === 'ADMIN') {
this.loadAdminConfig() loadAdminConfig()
} }
} else { } else {
toast.error('请先登录') toast.error('请先登录')
this.$router.push('/login') navigateTo('/login', { replace: true })
} }
this.isLoadingPage = false isLoadingPage.value = false
}, frosted.value = frostedState.enabled
methods: { })
onAvatarChange(e) {
const onAvatarChange = (e) => {
const file = e.target.files[0] const file = e.target.files[0]
if (file) { if (file) {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
this.tempAvatar = reader.result tempAvatar.value = reader.result
this.showCropper = true showCropper.value = true
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
}, }
onCropped({ file, url }) { watch(frosted, (val) => setFrosted(val))
this.avatarFile = file const onCropped = ({ file, url }) => {
this.avatar = url avatarFile.value = file
}, avatar.value = url
fetchPublishModes() { }
const fetchPublishModes = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' }, { id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' }, { id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
]) ])
}, }
fetchPasswordStrengths() { const fetchPasswordStrengths = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' }, { id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' }, { id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' }, { id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
]) ])
}, }
fetchAiLimits() { const fetchAiLimits = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 3, name: '3次' }, { id: 3, name: '3次' },
{ id: 5, name: '5次' }, { id: 5, name: '5次' },
{ id: 10, name: '10次' }, { id: 10, name: '10次' },
{ id: -1, name: '无限' }, { id: -1, name: '无限' },
]) ])
}, }
fetchRegisterModes() { const fetchRegisterModes = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' }, { id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' }, { id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
]) ])
}, }
async loadAdminConfig() { const loadAdminConfig = async () => {
try { try {
const token = getToken() const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/config`, { const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
@@ -160,31 +168,31 @@ export default {
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
this.publishMode = data.publishMode publishMode.value = data.publishMode
this.passwordStrength = data.passwordStrength passwordStrength.value = data.passwordStrength
this.aiFormatLimit = data.aiFormatLimit aiFormatLimit.value = data.aiFormatLimit
this.registerMode = data.registerMode registerMode.value = data.registerMode
} }
} catch (e) { } catch (e) {
// ignore // ignore
} }
}, }
async save() { const save = async () => {
this.isSaving = true isSaving.value = true
do { do {
let token = getToken() let token = getToken()
this.usernameError = '' usernameError.value = ''
if (!this.username) { if (!username.value) {
this.usernameError = '用户名不能为空' usernameError.value = '用户名不能为空'
} }
if (this.usernameError) { if (usernameError.value) {
toast.error(this.usernameError) toast.error(usernameError.value)
break break
} }
if (this.avatarFile) { if (avatarFile.value) {
const form = new FormData() const form = new FormData()
form.append('file', this.avatarFile) form.append('file', avatarFile.value)
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, { const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
@@ -192,7 +200,7 @@ export default {
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
this.avatar = data.url avatar.value = data.url
} else { } else {
toast.error(data.error || '上传失败') toast.error(data.error || '上传失败')
break break
@@ -201,7 +209,7 @@ export default {
const res = await fetch(`${API_BASE_URL}/api/users/me`, { const res = await fetch(`${API_BASE_URL}/api/users/me`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ username: this.username, introduction: this.introduction }), body: JSON.stringify({ username: username.value, introduction: introduction.value }),
}) })
const data = await res.json() const data = await res.json()
@@ -213,24 +221,22 @@ export default {
setToken(data.token) setToken(data.token)
token = data.token token = data.token
} }
if (this.role === 'ADMIN') { if (role.value === 'ADMIN') {
await fetch(`${API_BASE_URL}/api/admin/config`, { await fetch(`${API_BASE_URL}/api/admin/config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ body: JSON.stringify({
publishMode: this.publishMode, publishMode: publishMode.value,
passwordStrength: this.passwordStrength, passwordStrength: passwordStrength.value,
aiFormatLimit: this.aiFormatLimit, aiFormatLimit: aiFormatLimit.value,
registerMode: this.registerMode, registerMode: registerMode.value,
}), }),
}) })
} }
toast.success('保存成功') toast.success('保存成功')
} while (!this.isSaving) } while (!isSaving.value)
this.isSaving = false isSaving.value = false
},
},
} }
</script> </script>
@@ -305,6 +311,58 @@ export default {
max-width: 200px; max-width: 200px;
} }
.switch-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
max-width: 200px;
}
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.2s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: '';
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(20px);
}
.profile-section { .profile-section {
margin-bottom: 30px; margin-bottom: 30px;
} }

View File

@@ -18,63 +18,58 @@
</div> </div>
</template> </template>
<script> <script setup>
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const reason = ref('')
name: 'SignupReasonPageView', const error = ref('')
components: { BaseInput }, const isWaitingForRegister = ref(false)
data() { const token = ref('')
return { const route = useRoute()
reason: '',
error: '', onMounted(async () => {
isWaitingForRegister: false, token.value = route.query.token || ''
token: '', if (!token.value) {
await navigateTo({ path: '/signup' }, { replace: true })
} }
}, })
mounted() {
this.token = this.$route.query.token || '' const submit = async () => {
if (!this.token) { if (!reason.value || reason.value.trim().length < 20) {
this.$router.push('/signup') error.value = '请至少输入20个字'
}
},
methods: {
async submit() {
if (!this.reason || this.reason.trim().length < 20) {
this.error = '请至少输入20个字'
return return
} }
try { try {
this.isWaitingForRegister = true isWaitingForRegister.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, { const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
token: this.token, token: token.value,
reason: this.reason, reason: reason.value,
}), }),
}) })
this.isWaitingForRegister = false isWaitingForRegister.value = false
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
toast.success('注册理由已提交,请等待审核') toast.success('注册理由已提交,请等待审核')
this.$router.push('/') await navigateTo('/', { replace: true })
} else if (data.reason_code === 'INVALID_CREDENTIALS') { } else if (data.reason_code === 'INVALID_CREDENTIALS') {
toast.error('登录已过期,请重新登录') toast.error('登录已过期,请重新登录')
this.$router.push('/login') await navigateTo('/login', { replace: true })
} else { } else {
toast.error(data.error || '提交失败') toast.error(data.error || '提交失败')
} }
} catch (e) { } catch (e) {
this.isWaitingForRegister = false isWaitingForRegister.value = false
toast.error('提交失败') toast.error('提交失败')
} }
},
},
} }
</script> </script>

View File

@@ -89,115 +89,110 @@
</div> </div>
</template> </template>
<script> <script setup>
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { discordAuthorize } from '~/utils/discord' import { discordAuthorize } from '~/utils/discord'
import { githubAuthorize } from '~/utils/github' import { githubAuthorize } from '~/utils/github'
import { googleAuthorize } from '~/utils/google' import { googleAuthorize } from '~/utils/google'
import { twitterAuthorize } from '~/utils/twitter' import { twitterAuthorize } from '~/utils/twitter'
export default { const config = useRuntimeConfig()
name: 'SignupPageView', const API_BASE_URL = config.public.apiBaseUrl
components: { BaseInput }, const emailStep = ref(0)
setup() { const email = ref('')
return { googleAuthorize } const username = ref('')
}, const password = ref('')
data() { const registerMode = ref('DIRECT')
return { const emailError = ref('')
emailStep: 0, const usernameError = ref('')
email: '', const passwordError = ref('')
username: '', const code = ref('')
password: '', const isWaitingForEmailSent = ref(false)
registerMode: 'DIRECT', const isWaitingForEmailVerified = ref(false)
emailError: '',
usernameError: '', onMounted(async () => {
passwordError: '', username.value = route.query.u || ''
code: '',
isWaitingForEmailSent: false,
isWaitingForEmailVerified: false,
}
},
async mounted() {
this.username = this.$route.query.u || ''
try { try {
const res = await fetch(`${API_BASE_URL}/api/config`) const res = await fetch(`${API_BASE_URL}/api/config`)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
this.registerMode = data.registerMode registerMode.value = data.registerMode
} }
} catch { } catch {
/* ignore */ /* ignore */
} }
if (this.$route.query.verify) { if (route.query.verify) {
this.emailStep = 1 emailStep.value = 1
} }
}, })
methods: {
clearErrors() { const clearErrors = () => {
this.emailError = '' emailError.value = ''
this.usernameError = '' usernameError.value = ''
this.passwordError = '' passwordError.value = ''
}, }
async sendVerification() {
this.clearErrors() const sendVerification = async () => {
clearErrors()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(this.email)) { if (!emailRegex.test(email.value)) {
this.emailError = '邮箱格式不正确' emailError.value = '邮箱格式不正确'
} }
if (!this.password || this.password.length < 6) { if (!password.value || password.value.length < 6) {
this.passwordError = '密码至少6位' passwordError.value = '密码至少6位'
} }
if (!this.username) { if (!username.value) {
this.usernameError = '用户名不能为空' usernameError.value = '用户名不能为空'
} }
if (this.emailError || this.passwordError || this.usernameError) { if (emailError.value || passwordError.value || usernameError.value) {
return return
} }
try { try {
this.isWaitingForEmailSent = true isWaitingForEmailSent.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/register`, { const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
username: this.username, username: username.value,
email: this.email, email: email.value,
password: this.password, password: password.value,
}), }),
}) })
this.isWaitingForEmailSent = false isWaitingForEmailSent.value = false
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
this.emailStep = 1 emailStep.value = 1
toast.success('验证码已发送,请查看邮箱') toast.success('验证码已发送,请查看邮箱')
} else if (data.field) { } else if (data.field) {
if (data.field === 'username') this.usernameError = data.error if (data.field === 'username') usernameError.value = data.error
if (data.field === 'email') this.emailError = data.error if (data.field === 'email') emailError.value = data.error
if (data.field === 'password') this.passwordError = data.error if (data.field === 'password') passwordError.value = data.error
} else { } else {
toast.error(data.error || '发送失败') toast.error(data.error || '发送失败')
} }
} catch (e) { } catch (e) {
toast.error('发送失败') toast.error('发送失败')
} }
}, }
async verifyCode() {
const verifyCode = async () => {
try { try {
this.isWaitingForEmailVerified = true isWaitingForEmailVerified.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, { const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
code: this.code, code: code.value,
username: this.username, username: username.value,
}), }),
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
if (this.registerMode === 'WHITELIST') { if (registerMode.value === 'WHITELIST') {
this.$router.push('/signup-reason?token=' + data.token) navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
} else { } else {
toast.success('注册成功,请登录') toast.success('注册成功,请登录')
this.$router.push('/login') navigateTo('/login', { replace: true })
} }
} else { } else {
toast.error(data.error || '注册失败') toast.error(data.error || '注册失败')
@@ -205,19 +200,17 @@ export default {
} catch (e) { } catch (e) {
toast.error('注册失败') toast.error('注册失败')
} finally { } finally {
this.isWaitingForEmailVerified = false isWaitingForEmailVerified.value = false
} }
}, }
signupWithGithub() { const signupWithGithub = () => {
githubAuthorize() githubAuthorize()
}, }
signupWithDiscord() { const signupWithDiscord = () => {
discordAuthorize() discordAuthorize()
}, }
signupWithTwitter() { const signupWithTwitter = () => {
twitterAuthorize() twitterAuthorize()
},
},
} }
</script> </script>

View File

@@ -2,24 +2,20 @@
<CallbackPage /> <CallbackPage />
</template> </template>
<script> <script setup>
import CallbackPage from '~/components/CallbackPage.vue' import CallbackPage from '~/components/CallbackPage.vue'
import { twitterExchange } from '~/utils/twitter' import { twitterExchange } from '~/utils/twitter'
export default { onMounted(async () => {
name: 'TwitterCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const code = url.searchParams.get('code') const code = url.searchParams.get('code')
const state = url.searchParams.get('state') const state = url.searchParams.get('state')
const result = await twitterExchange(code, state, '') const result = await twitterExchange(code, state, '')
if (result.needReason) { if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token) navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else { } else {
this.$router.push('/') navigateTo('/', { replace: true })
} }
}, })
}
</script> </script>

View File

@@ -35,10 +35,12 @@
/> />
<div class="profile-level-target"> <div class="profile-level-target">
目标 Lv.{{ levelInfo.currentLevel + 1 }} 目标 Lv.{{ levelInfo.currentLevel + 1 }}
<i <ToolTip
class="fas fa-info-circle profile-exp-info" content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。" placement="bottom"
></i> >
<i class="fas fa-info-circle profile-exp-info"></i>
</ToolTip>
</div> </div>
</div> </div>
</div> </div>
@@ -204,12 +206,33 @@
</div> </div>
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline"> <div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
<div class="timeline-tabs">
<div
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
@click="timelineFilter = 'all'"
>
全部
</div>
<div
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
@click="timelineFilter = 'articles'"
>
文章
</div>
<div
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
@click="timelineFilter = 'comments'"
>
评论和回复
</div>
</div>
<BasePlaceholder <BasePlaceholder
v-if="timelineItems.length === 0" v-if="filteredTimelineItems.length === 0"
text="暂无时间线" text="暂无时间线"
icon="fas fa-inbox" icon="fas fa-inbox"
/> />
<BaseTimeline :items="timelineItems"> <div class="timeline-list">
<BaseTimeline :items="filteredTimelineItems">
<template #item="{ item }"> <template #item="{ item }">
<template v-if="item.type === 'post'"> <template v-if="item.type === 'post'">
发布了文章 发布了文章
@@ -266,6 +289,7 @@
</template> </template>
</BaseTimeline> </BaseTimeline>
</div> </div>
</div>
<div v-else-if="selectedTab === 'following'" class="follow-container"> <div v-else-if="selectedTab === 'following'" class="follow-container">
<div class="follow-tabs"> <div class="follow-tabs">
@@ -296,51 +320,57 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '~/main' import AchievementList from '~/components/AchievementList.vue'
import { getToken, authState } from '~/utils/auth'
import BaseTimeline from '~/components/BaseTimeline.vue'
import UserList from '~/components/UserList.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import LevelProgress from '~/components/LevelProgress.vue' import LevelProgress from '~/components/LevelProgress.vue'
import UserList from '~/components/UserList.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { prevLevelExp } from '~/utils/level'
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown' import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { prevLevelExp } from '~/utils/level' const config = useRuntimeConfig()
import AchievementList from '~/components/AchievementList.vue' const API_BASE_URL = config.public.apiBaseUrl
definePageMeta({ definePageMeta({
alias: ['/users/:id/'], alias: ['/users/:id/'],
}) })
const route = useRoute()
const router = useRouter()
const username = route.params.id
export default { const user = ref({})
name: 'ProfileView', const hotPosts = ref([])
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList }, const hotReplies = ref([])
setup() { const hotTags = ref([])
const route = useRoute() const timelineItems = ref([])
const router = useRouter() const timelineFilter = ref('all')
const username = route.params.id const filteredTimelineItems = computed(() => {
if (timelineFilter.value === 'articles') {
const user = ref({}) return timelineItems.value.filter((item) => item.type === 'post')
const hotPosts = ref([]) } else if (timelineFilter.value === 'comments') {
const hotReplies = ref([]) return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
const hotTags = ref([]) }
const timelineItems = ref([]) return timelineItems.value
const followers = ref([]) })
const followings = ref([]) const followers = ref([])
const medals = ref([]) const followings = ref([])
const subscribed = ref(false) const medals = ref([])
const isLoading = ref(true) const subscribed = ref(false)
const tabLoading = ref(false) const isLoading = ref(true)
const selectedTab = ref( const tabLoading = ref(false)
const selectedTab = ref(
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab) ['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
? route.query.tab ? route.query.tab
: 'summary', : 'summary',
) )
const followTab = ref('followers') const followTab = ref('followers')
const levelInfo = computed(() => { const levelInfo = computed(() => {
const exp = user.value.experience || 0 const exp = user.value.experience || 0
const currentLevel = user.value.currentLevel || 0 const currentLevel = user.value.currentLevel || 0
const nextExp = user.value.nextLevelExp || 0 const nextExp = user.value.nextLevelExp || 0
@@ -349,20 +379,20 @@ export default {
const ratio = total > 0 ? (exp - prevExp) / total : 1 const ratio = total > 0 ? (exp - prevExp) / total : 1
const percent = Math.max(0, Math.min(1, ratio)) * 100 const percent = Math.max(0, Math.min(1, ratio)) * 100
return { exp, currentLevel, nextExp, percent } return { exp, currentLevel, nextExp, percent }
}) })
const isMine = computed(function () { const isMine = computed(function () {
const mine = authState.username === username || String(authState.userId) === username const mine = authState.username === username || String(authState.userId) === username
console.log(mine) console.log(mine)
return mine return mine
}) })
const formatDate = (d) => { const formatDate = (d) => {
if (!d) return '' if (!d) return ''
return TimeManager.format(d) return TimeManager.format(d)
} }
const fetchUser = async () => { const fetchUser = async () => {
const token = getToken() const token = getToken()
const headers = token ? { Authorization: `Bearer ${token}` } : {} const headers = token ? { Authorization: `Bearer ${token}` } : {}
const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers }) const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers })
@@ -373,9 +403,9 @@ export default {
} else if (res.status === 404) { } else if (res.status === 404) {
router.replace('/404') router.replace('/404')
} }
} }
const fetchSummary = async () => { const fetchSummary = async () => {
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`) const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
if (postsRes.ok) { if (postsRes.ok) {
const data = await postsRes.json() const data = await postsRes.json()
@@ -393,9 +423,9 @@ export default {
const data = await tagsRes.json() const data = await tagsRes.json()
hotTags.value = data.map((t) => ({ icon: 'fas fa-tag', tag: t })) hotTags.value = data.map((t) => ({ icon: 'fas fa-tag', tag: t }))
} }
} }
const fetchTimeline = async () => { const fetchTimeline = async () => {
const [postsRes, repliesRes, tagsRes] = await Promise.all([ const [postsRes, repliesRes, tagsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`), fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`), fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`),
@@ -426,36 +456,36 @@ export default {
] ]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
timelineItems.value = mapped timelineItems.value = mapped
} }
const fetchFollowUsers = async () => { const fetchFollowUsers = async () => {
const [followerRes, followingRes] = await Promise.all([ const [followerRes, followingRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/followers`), fetch(`${API_BASE_URL}/api/users/${username}/followers`),
fetch(`${API_BASE_URL}/api/users/${username}/following`), fetch(`${API_BASE_URL}/api/users/${username}/following`),
]) ])
followers.value = followerRes.ok ? await followerRes.json() : [] followers.value = followerRes.ok ? await followerRes.json() : []
followings.value = followingRes.ok ? await followingRes.json() : [] followings.value = followingRes.ok ? await followingRes.json() : []
} }
const loadSummary = async () => { const loadSummary = async () => {
tabLoading.value = true tabLoading.value = true
await fetchSummary() await fetchSummary()
tabLoading.value = false tabLoading.value = false
} }
const loadTimeline = async () => { const loadTimeline = async () => {
tabLoading.value = true tabLoading.value = true
await fetchTimeline() await fetchTimeline()
tabLoading.value = false tabLoading.value = false
} }
const loadFollow = async () => { const loadFollow = async () => {
tabLoading.value = true tabLoading.value = true
await fetchFollowUsers() await fetchFollowUsers()
tabLoading.value = false tabLoading.value = false
} }
const fetchAchievements = async () => { const fetchAchievements = async () => {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`) const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
if (res.ok) { if (res.ok) {
medals.value = await res.json() medals.value = await res.json()
@@ -463,15 +493,15 @@ export default {
medals.value = [] medals.value = []
toast.error('获取成就失败') toast.error('获取成就失败')
} }
} }
const loadAchievements = async () => { const loadAchievements = async () => {
tabLoading.value = true tabLoading.value = true
await fetchAchievements() await fetchAchievements()
tabLoading.value = false tabLoading.value = false
} }
const subscribeUser = async () => { const subscribeUser = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -487,9 +517,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const unsubscribeUser = async () => { const unsubscribeUser = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -505,14 +535,14 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const gotoTag = (tag) => { const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name) const value = encodeURIComponent(tag.id ?? tag.name)
router.push({ path: '/', query: { tags: value } }) navigateTo({ path: '/', query: { tags: value } }, { replace: true })
} }
const init = async () => { const init = async () => {
try { try {
await fetchUser() await fetchUser()
if (selectedTab.value === 'summary') { if (selectedTab.value === 'summary') {
@@ -529,54 +559,20 @@ export default {
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
onMounted(init) onMounted(init)
watch(selectedTab, async (val) => { watch(selectedTab, async (val) => {
// router.replace({ query: { ...route.query, tab: val } }) // router.replace({ query: { ...route.query, tab: val } })
if (val === 'timeline' && timelineItems.value.length === 0) { if (val === 'timeline' && timelineItems.value.length === 0) {
await loadTimeline() await loadTimeline()
} else if ( } else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
val === 'following' &&
followers.value.length === 0 &&
followings.value.length === 0
) {
await loadFollow() await loadFollow()
} else if (val === 'achievements' && medals.value.length === 0) { } else if (val === 'achievements' && medals.value.length === 0) {
await loadAchievements() await loadAchievements()
} }
}) })
return {
user,
hotPosts,
hotReplies,
timelineItems,
followers,
followings,
medals,
subscribed,
isMine,
isLoading,
tabLoading,
selectedTab,
followTab,
formatDate,
stripMarkdown,
stripMarkdownLength,
loadTimeline,
loadFollow,
loadAchievements,
loadSummary,
subscribeUser,
unsubscribeUser,
gotoTag,
hotTags,
levelInfo,
}
},
}
</script> </script>
<style scoped> <style scoped>
@@ -691,12 +687,6 @@ export default {
opacity: 0.8; opacity: 0.8;
} }
.profile-exp-info {
margin-left: 4px;
opacity: 0.5;
cursor: pointer;
}
.profile-info { .profile-info {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -737,6 +727,7 @@ export default {
border-bottom: 1px solid var(--normal-border-color); border-bottom: 1px solid var(--normal-border-color);
scrollbar-width: none; scrollbar-width: none;
overflow-x: auto; overflow-x: auto;
backdrop-filter: var(--blur-10);
} }
.profile-tabs-item { .profile-tabs-item {
@@ -814,8 +805,24 @@ export default {
width: 40%; width: 40%;
} }
.profile-timeline { .timeline-tabs {
padding: 20px; display: flex;
flex-direction: row;
border-bottom: 1px solid var(--normal-border-color);
}
.timeline-list {
padding: 10px 20px;
}
.timeline-tab-item {
padding: 10px 20px;
cursor: pointer;
}
.timeline-tab-item.selected {
color: var(--primary-color);
border-bottom: 2px solid var(--primary-color);
} }
.timeline-date { .timeline-date {

View File

@@ -1,3 +1,4 @@
import { defineNuxtPlugin } from 'nuxt/app'
import ClickOutside from '~/directives/clickOutside.js' import ClickOutside from '~/directives/clickOutside.js'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {

View File

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

View File

@@ -1,5 +1,5 @@
// plugins/ldrs.client.ts // plugins/ldrs.client.ts
import { defineNuxtPlugin } from '#app' import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin(async () => { export default defineNuxtPlugin(async () => {
// 动态引入,防止打包时把 ldrs 拉进 SSR bundle // 动态引入,防止打包时把 ldrs 拉进 SSR bundle

View File

@@ -1,5 +1,6 @@
import NProgress from 'nprogress' import NProgress from 'nprogress'
import 'nprogress/nprogress.css' import 'nprogress/nprogress.css'
import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
NProgress.configure({ showSpinner: false }) NProgress.configure({ showSpinner: false })
@@ -12,7 +13,7 @@ export default defineNuxtPlugin((nuxtApp) => {
NProgress.done() NProgress.done()
}) })
nuxtApp.hook('page:error', () => { nuxtApp.hook('app:error', () => {
NProgress.done() NProgress.done()
}) })
}) })

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app' import { defineNuxtPlugin } from 'nuxt/app'
import { initTheme } from '~/utils/theme' import { initTheme } from '~/utils/theme'
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app' import { defineNuxtPlugin } from 'nuxt/app'
import 'vue-toastification/dist/index.css' import 'vue-toastification/dist/index.css'
import '~/assets/toast.css' import '~/assets/toast.css'

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More