Compare commits

..

131 Commits

Author SHA1 Message Date
Tim
2f339fdbdb feat: enable multi-option polls 2025-08-31 12:13:41 +08:00
tim
3808becc8b fix: 多选ui 2025-08-31 11:25:34 +08:00
tim
18db4d7317 fix: toolbar 层级修改 2025-08-31 11:14:48 +08:00
Tim
52cbb71945 Merge pull request #800 from nagisa77/codex/refactor-voting-and-lottery-into-components-zk6hvx
refactor: extract poll and lottery components
2025-08-31 11:10:46 +08:00
Tim
39c34a9048 feat: add PostPoll and PostLottery components 2025-08-31 11:10:20 +08:00
tim
4baabf2224 Revert "refactor: extract poll and lottery sections"
This reverts commit 27efc493b2.
2025-08-31 11:09:22 +08:00
Tim
8023183bc6 Merge pull request #799 from nagisa77/codex/refactor-voting-and-lottery-into-components
refactor: extract poll and lottery sections
2025-08-31 11:08:05 +08:00
Tim
27efc493b2 refactor: extract poll and lottery sections 2025-08-31 11:07:49 +08:00
tim
ca6e45a711 fix: 适配夜间模式 2025-08-31 10:55:40 +08:00
tim
803ca9e103 新的通知类型适配 2025-08-31 02:06:32 +08:00
Tim
9d1e12773a Merge pull request #796 from nagisa77/codex/modify-voting-module-components
Refactor poll module and add poll notifications
2025-08-31 01:49:55 +08:00
Tim
5a09934866 refactor poll and lottery forms, add poll notifications 2025-08-31 01:49:37 +08:00
tim
db1d7981c5 fix: checked修改为false 2025-08-31 01:21:52 +08:00
tim
6e1a7c773c fix: 投票模块采用clientOnly 2025-08-31 01:19:48 +08:00
tim
ac4f1064e7 fix: 结束时只显示结果 2025-08-31 01:05:00 +08:00
Tim
4e98fd6a89 Merge pull request #795 from nagisa77/codex/add-real-data-integration-for-voting-page
feat: render poll results with real data
2025-08-31 00:14:38 +08:00
Tim
1bf92ab1ad feat: render poll results with real data 2025-08-31 00:14:12 +08:00
tim
c6ab431c87 fix: 页面适配 2025-08-31 00:04:35 +08:00
Tim
aaa25d5c2f Merge pull request #794 from nagisa77/codex/add-participant-info-to-vote-response-y233h3
feat: return poll option participants
2025-08-30 12:07:01 +08:00
Tim
569531b462 feat: add poll vote repository 2025-08-30 12:06:11 +08:00
tim
c3ae97f8ba Revert "feat: track poll votes"
This reverts commit 23582934fa.
2025-08-30 12:05:35 +08:00
Tim
a57f3e6406 Merge pull request #793 from nagisa77/codex/add-participant-info-to-vote-response
feat: expose poll option participants
2025-08-30 12:03:34 +08:00
Tim
23582934fa feat: track poll votes 2025-08-30 12:03:17 +08:00
Tim
5adee4db0e Merge pull request #792 from nagisa77/codex/add-voting-feature-to-post
feat: add poll post support
2025-08-29 23:56:41 +08:00
Tim
a2ccc95b4e feat: add poll post support 2025-08-29 23:56:03 +08:00
Tim
dc5eb5a637 Merge pull request #791 from nagisa77/codex/add-voting-post-type
feat: add poll post type
2025-08-29 22:37:12 +08:00
Tim
55dd36bd24 feat: add poll post type 2025-08-29 22:36:36 +08:00
Tim
59232f99ca Merge pull request #790 from nagisa77/feature/menu_ui
fix: UI部份美化
2025-08-29 21:03:43 +08:00
tim
f93f58b055 fix: UI部份美化 2025-08-29 21:02:23 +08:00
Tim
8ad35af199 Merge pull request #789 from nagisa77/feature/menu_ui
fix: 首页 & 全局文字优化
2025-08-29 20:39:31 +08:00
tim
d427a41f6d fix: 首页 & 全局文字优化 2025-08-29 20:38:55 +08:00
Tim
ea53bc3c83 Create CODE_OF_CONDUCT.md 2025-08-29 18:15:26 +08:00
Tim
3a39cfdb49 Update issue templates 2025-08-29 18:14:05 +08:00
Tim
3d1b8b8e6e Update issue templates 2025-08-29 18:08:47 +08:00
Tim
f0e58d1efe Create LICENSE 2025-08-29 18:07:41 +08:00
Tim
5c4aca5ab8 Merge pull request #785 from nagisa77/feature/menu_ui
fix: menu ui
2025-08-29 14:52:41 +08:00
tim
fff59e800d fix: menu ui 2025-08-29 14:51:40 +08:00
Tim
b42ed19160 Merge pull request #784 from nagisa77/feature/menu_ui
feat: MENU UI 优化
2025-08-29 14:42:47 +08:00
tim
6fd663d983 feat: MENU UI 优化 2025-08-29 14:41:29 +08:00
Tim
fd6fc11630 Merge pull request #779 from CH-122/fix/comment
更新评论项组件,添加回复用户头像和样式优化
2025-08-29 14:01:17 +08:00
CH-122
d7bfeed259 feat: 更新评论项组件,增加回复用户头像点击事件,取消使用 replace 2025-08-29 13:59:02 +08:00
Tim
c5e4da5e07 Merge pull request #781 from nagisa77/feature/daily_bugfix_0829
fix: 新增ipx依赖,新增node环境说明
2025-08-29 10:21:50 +08:00
Tim
b87932560b fix: 新增ipx依赖,新增node环境说明 2025-08-29 10:20:54 +08:00
CH-122
58ff8b177e fix: 更新评论项组件,添加回复用户头像和样式优化 2025-08-29 08:51:56 +08:00
Tim
4f6b585735 Merge pull request #776 from nagisa77/codex/add-mermaid-rendering-support
feat: add mermaid support to markdown rendering
2025-08-28 16:55:24 +08:00
Tim
ac81bccd20 feat: add mermaid support to markdown 2025-08-28 16:52:07 +08:00
Tim
351447e3d1 Merge pull request #773 from sivdead/main
解决文章和草稿长度不够的问题
2025-08-28 15:59:45 +08:00
sivdead
453d8fa68b refactor(model): 将 Post和Draft 实体的内容字段类型从 TEXT 改为 LONGTEXT 2025-08-28 15:11:14 +08:00
tim
2c5b38ee9e fix: 修复 http://localhost:3000/posts/310 内容超出的问题 2025-08-28 14:40:19 +08:00
Tim
b5fd5a3edc Update README.md 2025-08-28 10:16:12 +08:00
tim
ee717aced2 fix: 处理两处图片加载异常问题 2025-08-28 09:19:06 +08:00
Tim
9a9152593e Merge pull request #764 from nagisa77/feature/daily_bugfix_0827
fix: 简化time规则
2025-08-27 20:58:47 +08:00
Tim
856d3dd513 fix: 简化time规则 2025-08-27 20:57:20 +08:00
Tim
0e42a3335a Merge pull request #763 from nagisa77/feature/daily_bugfix_0827
fix: 新增相对时间
2025-08-27 20:48:57 +08:00
Tim
d96aae59d2 fix: 新增相对时间 2025-08-27 20:47:50 +08:00
Tim
122722d0e9 Merge pull request #762 from nagisa77/codex/fix-overlap-of-mobile-post-button
Adjust mobile post icon position and add close button to message box
2025-08-27 20:40:41 +08:00
Tim
0c2264e509 fix: adjust new post icon position and close message window 2025-08-27 20:39:46 +08:00
Tim
1e503e26f2 Merge pull request #761 from nagisa77/feature/daily_bugfix_0827
fix: 帖子可被刷积分,应新增取消赞消除积分😂 #685
2025-08-27 20:28:49 +08:00
Tim
ec0fd63e30 fix: 帖子可被刷积分,应新增取消赞消除积分😂 #685 2025-08-27 20:27:53 +08:00
Tim
dfd4c70b6e Merge pull request #760 from nagisa77/codex/fix-repeated-reaction-notifications-and-points
fix: resolve repeated reaction issues
2025-08-27 20:23:43 +08:00
Tim
d79dc8877d fix: handle reaction notification and point deduction 2025-08-27 20:23:28 +08:00
Tim
e979350d40 Merge pull request #759 from nagisa77/feature/daily_bugfix_0827
Feature/daily bugfix 0827
2025-08-27 20:21:58 +08:00
Tim
99bf80a47a Merge pull request #758 from nagisa77/codex-mv1xa5
fix: gray out unearned medals
2025-08-27 20:21:23 +08:00
Tim
bfadda1e7d fix: gray out unearned medals 2025-08-27 20:21:05 +08:00
Tim
906998a07f Merge pull request #757 from nagisa77/feature/daily_bugfix_0827
fix: 站内信 scroll问题 #749
2025-08-27 20:19:29 +08:00
Tim
02287c05be fix: 站内信 scroll问题 #749 2025-08-27 20:18:39 +08:00
Tim
56aed4603e Merge pull request #756 from nagisa77/feature/daily_bugfix_0827
fix: 站内信 scroll问题 #749
2025-08-27 20:01:34 +08:00
Tim
a1fa7b2d5b fix: 站内信 scroll问题 #749 2025-08-27 20:00:14 +08:00
Tim
083c7980c6 Merge pull request #755 from nagisa77/feature/daily_bugfix_0827
fix:  回复表情通知为空的问题 #735
2025-08-27 19:45:21 +08:00
Tim
3d51f29be7 fix: 回复表情通知为空的问题 #735 2025-08-27 19:44:27 +08:00
Tim
d243e3a9d6 Merge pull request #752 from nagisa77/feature/daily_bugfix_0827
feature: 积分趋势统计
2025-08-27 15:56:33 +08:00
Tim
2b3c60f9a7 fix: 新增积分趋势统计 2025-08-27 15:55:20 +08:00
Tim
8b948a20cd Merge pull request #751 from nagisa77/codex/add-91zeiv
feat: show 30-day point trend chart
2025-08-27 15:43:33 +08:00
Tim
5053ac213d test(points): cover trend endpoint 2025-08-27 15:42:49 +08:00
Tim
e5ec801785 Merge pull request #750 from nagisa77/feature/daily_bugfix_0827
fix: 修复贴吧表情显示问题
2025-08-27 15:27:16 +08:00
Tim
31e25232d0 fix: 修复贴吧表情显示问题 2025-08-27 15:26:23 +08:00
Tim
cdc92aeebe Merge pull request #744 from nagisa77/feature/daily_bugfix_0825_c
fix: iOS修复blur问题
2025-08-27 13:21:19 +08:00
Tim
d2c2213197 fix: iOS修复blur问题 2025-08-27 13:20:42 +08:00
Tim
c687ffed54 Merge pull request #742 from nagisa77/feature/daily_bugfix_0825_c
fix: svg 采用本地,避免加载不了
2025-08-27 12:48:42 +08:00
Tim
5bc9ff45d7 fix: svg 采用本地,避免加载不了 2025-08-27 12:47:56 +08:00
Tim
78c7681bc8 Merge pull request #734 from nagisa77/feature/daily_bugfix_0825_c
daily bugfix
2025-08-27 12:36:00 +08:00
Tim
5eb206a358 fix: use base tabs 2025-08-27 12:34:28 +08:00
Tim
18179cca22 Merge pull request #741 from nagisa77/codex/create-reusable-multi-tabs-component-kvi40j
feat: add reusable swipeable tabs component
2025-08-27 12:31:11 +08:00
Tim
2b28cb2ac1 feat: add reusable swipeable tabs component 2025-08-27 12:30:56 +08:00
Tim
610a645092 Revert "feat: create BaseTabs component"
This reverts commit 0fc1415a14.
2025-08-27 12:30:08 +08:00
Tim
504ca55cad Merge pull request #740 from nagisa77/codex/create-reusable-multi-tabs-component-d2xsuk
feat: unify tab navigation with reusable swipeable component
2025-08-27 12:26:58 +08:00
Tim
0fc1415a14 feat: create BaseTabs component 2025-08-27 12:26:35 +08:00
Tim
50a84220fe Revert "feat: add reusable multi-tabs component"
This reverts commit e8a162d859.
2025-08-27 12:25:44 +08:00
Tim
af3e049c23 Merge branch 'feature/daily_bugfix_0825_c' of github.com:nagisa77/OpenIsle into feature/daily_bugfix_0825_c 2025-08-27 12:22:55 +08:00
Tim
c33b411659 Merge pull request #739 from nagisa77/codex/create-reusable-multi-tabs-component-j58zes
feat: add reusable multi-tabs component
2025-08-27 12:22:40 +08:00
Tim
e8a162d859 feat: add reusable multi-tabs component 2025-08-27 12:22:22 +08:00
Tim
e819926cf3 fix: 取消chunks分割,避免css覆盖问题 2025-08-27 12:08:50 +08:00
Tim
013d47e8e4 fix: 前端修改:图片loading做一个适配,现在图片没加载出来会出现如下情况, 不丝滑 2025-08-27 12:07:23 +08:00
Tim
6cc76593e4 Merge pull request #737 from nagisa77/codex/abstract-nuxtimg-with-placeholder-3oo626
feat: add Nuxt image component with LQIP placeholder
2025-08-27 11:47:14 +08:00
Tim
a2a08331e2 feat: add BaseImage component with blur placeholder 2025-08-27 11:46:57 +08:00
tim
3eabafadf8 fix: UI修改,采用baseplaceholder #732 2025-08-26 23:18:07 +08:00
tim
62c1983fd5 fix: markdown 支持 video 2025-08-26 13:39:33 +08:00
tim
689b719e18 fix: use keys count 2025-08-26 13:18:20 +08:00
tim
c6eccb01b9 fix: 样式修改 2025-08-26 13:14:03 +08:00
tim
cdf7e61157 Revert "fix: use keys count"
This reverts commit d23511ecb9.
2025-08-26 13:09:20 +08:00
tim
d23511ecb9 fix: use keys count 2025-08-26 13:06:05 +08:00
Tim
c76708d2ff Merge pull request #725 from nagisa77/feature/daily_bugfix_0826
0826 daily bugfix
2025-08-26 11:22:10 +08:00
Tim
d978bd428e fix: 积分icon优化 2025-08-26 11:21:43 +08:00
Tim
e5954cfb62 Merge pull request #730 from nagisa77/codex/add-point-system-for-lottery-participation
feat: integrate points with lottery participation
2025-08-26 11:14:36 +08:00
Tim
cb614b9739 feat: integrate points with lottery participation 2025-08-26 11:14:20 +08:00
Tim
88ce6b682d fix: 抽奖ui 优化 2025-08-26 10:59:54 +08:00
Tim
e02db635c4 fix: 调整收藏样式 2025-08-26 10:52:53 +08:00
Tim
231379181a Merge pull request #729 from nagisa77/codex/add-tab-for-favorite-articles
feat: add favorites tab to user profile
2025-08-26 10:49:01 +08:00
Tim
bd9ce67d4b feat: add favorites tab to user profile 2025-08-26 10:48:38 +08:00
Tim
6527b3790e fix: add link logo, 以及跳转新窗口 2025-08-26 10:47:02 +08:00
Tim
f01e8c942a Merge remote-tracking branch 'origin/main' into feature/daily_bugfix_0826 2025-08-26 10:34:21 +08:00
Tim
1e1ae29d32 fix: reactions面板,超过三种reaction才采用省略样式 而不是三个 #724 2025-08-26 10:33:45 +08:00
Tim
d31a8bfee4 Merge pull request #726 from WoJiaoFuXiaoYun/main
fix: 修复小窗口点击站内链接,会从小窗直接跳,预期主窗口跳转 #723
2025-08-26 10:33:21 +08:00
WangHe
29a96595f7 fix: 修复小窗口点击站内链接,会从小窗直接跳,预期主窗口跳转 #723 2025-08-26 10:14:28 +08:00
Tim
2b242367d7 fix: 站内信:从红点点进去又退出来,没有消退红点,新信息也没适配 #712 2025-08-26 10:12:16 +08:00
Tim
3f0cd2bf0f Merge pull request #720 from nagisa77/feature/daily_bugfix_0825_b
Feature/daily bugfix 0825 b
2025-08-25 20:38:28 +08:00
Tim
a98a631378 Revert "feat: add message float components"
This reverts commit b0eef220a6.
2025-08-25 20:38:10 +08:00
Tim
7701d359dc fix: 允许窗口收起 2025-08-25 20:35:33 +08:00
Tim
ffd9ef8a32 fix: 新增交互 2025-08-25 19:25:06 +08:00
Tim
36cd5ab171 Merge pull request #722 from nagisa77/codex/add-floating-window-support-for-message-box-a7msu4
feat: add floating message box window
2025-08-25 17:20:30 +08:00
Tim
58d86fa065 Merge branch 'feature/daily_bugfix_0825_b' into codex/add-floating-window-support-for-message-box-a7msu4 2025-08-25 17:20:23 +08:00
Tim
df71cf901b feat: add floating message box window 2025-08-25 17:18:34 +08:00
Tim
ac3fc6702a Merge pull request #721 from nagisa77/codex/add-floating-window-support-for-message-box
feat: add floating message window
2025-08-25 17:12:37 +08:00
Tim
b0eef220a6 feat: add message float components 2025-08-25 17:12:21 +08:00
Tim
02d366e2c7 fix: 支持回复/reactions 2025-08-25 17:06:44 +08:00
Tim
6409531a64 Merge pull request #719 from nagisa77/codex/add-reply-and-reaction-support-to-messages
feat: support message replies and reactions
2025-08-25 16:45:49 +08:00
Tim
175ab79b27 feat: support message replies and reactions 2025-08-25 16:42:14 +08:00
Tim
b543953d22 Revert "feat: support floating message box"
This reverts commit cd73747164.

# Conflicts:
#	frontend_nuxt/pages/message-box/index.vue
2025-08-25 15:51:02 +08:00
Tim
b4fef68af5 Merge branch 'feature/daily_bugfix_0825_b' of github.com:nagisa77/OpenIsle into feature/daily_bugfix_0825_b 2025-08-25 15:45:19 +08:00
Tim
6c48a38212 feat: delete router 2025-08-25 15:44:14 +08:00
Tim
8a3e4d8e98 Merge pull request #718 from nagisa77/codex/add-floating-window-support
feat: add floating message window
2025-08-25 15:43:43 +08:00
96 changed files with 7065 additions and 2459 deletions

View File

@@ -0,0 +1,20 @@
---
name: 新功能建议
about: 请为该项目提出一个想法
title: ''
labels: ''
assignees: ''
---
**你的功能请求是否与某个问题相关?请描述。**
请清晰、简洁地说明问题。例如:“我经常因为……而感到困扰。”
**你期望的解决方案**
请清晰、简洁地描述你希望发生的事情/功能如何工作。
**你考虑过的替代方案**
请清晰、简洁地说明你已考虑过的其他解决方案或功能。
**其他上下文**
在此添加与功能请求相关的其他信息或截图。

View File

@@ -0,0 +1,41 @@
---
name: 错误/Bug报告
about: 创建报告以帮助我们改进
title: ''
labels: ''
assignees: ''
---
**描述 Bug**
对该 Bug 进行清晰简明的描述。
**复现步骤**
复现该问题的步骤:
1. 进入 '...'
2. 点击 '...'
3. 下拉到 '...'
4. 看到错误
**预期行为**
清晰简明地描述你期望发生的情况。
**截图**
如果适用,请添加截图以帮助解释问题。
**桌面端(请完成以下信息):**
* 操作系统:\[例如 iOS]
* 浏览器:\[例如 Chrome、Safari]
* 版本:\[例如 22]
**移动端(请完成以下信息):**
* 设备:\[例如 iPhone6]
* 操作系统:\[例如 iOS8.1]
* 浏览器:\[例如 系统自带浏览器、Safari]
* 版本:\[例如 22]
**附加上下文**
在此添加与问题相关的其他上下文信息。

32
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,32 @@
# OpenIsle Code of Conduct
Like the technical community as a whole, the OpenIsle team and community is made up of a mixture of professionals and volunteers from all over the world, working on every aspect of the mission - including mentorship, teaching, and connecting people.
Diversity is one of our huge strengths, but it can also lead to communication issues and unhappiness. To that end, we have a few ground rules that we ask people to adhere to. This code applies equally to founders, mentors and those seeking help and guidance.
This isnt an exhaustive list of things that you cant do. Rather, take it in the spirit in which its intended - a guide to make it easier to enrich all of us and the technical communities in which we participate.
This code of conduct applies to all spaces managed by the OpenIsle project or . This includes IRC, the mailing lists, the issue tracker, DSF events, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them.
If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our
- **Be friendly and patient.**
- **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability.
- **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
- **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. Its important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the OpenIsle community should be respectful when dealing with other members as well as with people outside the OpenIsle community.
- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
- Violent threats or language directed against another person.
- Discriminatory jokes and language.
- Posting sexually explicit or violent material.
- Posting (or threatening to post) other people's personally identifying information ("doxing").
- Personal insults, especially those using racist or sexist terms.
- Unwelcome sexual attention.
- Advocating for, or encouraging, any of the above behavior.
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and OpenIsle is no exception. It is important that we resolve disagreements and differing views constructively. Remember that were different. The strength of OpenIsle comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesnt mean that theyre wrong. Dont forget that it is human to err and blaming each other doesnt get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html).
## Questions?
If you have questions, please see . If that doesn't answer your questions, feel free to [contact us](mailto:).

View File

@@ -58,6 +58,8 @@ cp open-isle.env.example open-isle.env
> Step3 前端部署
**⚠️ 环境要求Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
```shell

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Tim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -5,7 +5,6 @@ import com.openisle.dto.ConversationDto;
import com.openisle.dto.CreateConversationRequest;
import com.openisle.dto.CreateConversationResponse;
import com.openisle.dto.MessageDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.model.Message;
import com.openisle.model.MessageConversation;
import com.openisle.model.User;
@@ -55,16 +54,16 @@ public class MessageController {
@PostMapping
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent());
return ResponseEntity.ok(toDto(message));
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/messages")
public ResponseEntity<MessageDto> sendMessageToConversation(@PathVariable Long conversationId,
@RequestBody ChannelMessageRequest req,
Authentication auth) {
Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent());
return ResponseEntity.ok(toDto(message));
Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/read")
@@ -79,23 +78,6 @@ public class MessageController {
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
}
private MessageDto toDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setCreatedAt(message.getCreatedAt());
dto.setConversationId(message.getConversation().getId());
UserSummaryDto senderDto = new UserSummaryDto();
senderDto.setId(message.getSender().getId());
senderDto.setUsername(message.getSender().getUsername());
senderDto.setAvatar(message.getSender().getAvatar());
dto.setSender(senderDto);
return dto;
}
@GetMapping("/unread-count")
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
@@ -105,6 +87,7 @@ public class MessageController {
static class MessageRequest {
private Long recipientId;
private String content;
private Long replyToId;
public Long getRecipientId() {
return recipientId;
@@ -121,10 +104,19 @@ public class MessageController {
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
static class ChannelMessageRequest {
private String content;
private Long replyToId;
public String getContent() {
return content;
@@ -133,5 +125,13 @@ public class MessageController {
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
}

View File

@@ -7,9 +7,11 @@ import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@@ -25,4 +27,10 @@ public class PointHistoryController {
.map(pointHistoryMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/trend")
public List<Map<String, Object>> trend(Authentication auth,
@RequestParam(value = "days", defaultValue = "30") int days) {
return pointService.trend(auth.getName(), days);
}
}

View File

@@ -3,6 +3,7 @@ package com.openisle.controller;
import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostRequest;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.PollDto;
import com.openisle.mapper.PostMapper;
import com.openisle.model.Post;
import com.openisle.service.*;
@@ -41,7 +42,9 @@ public class PostController {
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
req.getTitle(), req.getContent(), req.getTagIds(),
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
req.getPrizeCount(), req.getStartTime(), req.getEndTime());
req.getPrizeCount(), req.getPointCost(),
req.getStartTime(), req.getEndTime(),
req.getOptions(), req.getMultiple());
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
@@ -85,6 +88,17 @@ public class PostController {
return ResponseEntity.ok().build();
}
@GetMapping("/{id}/poll/progress")
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
}
@PostMapping("/{id}/poll/vote")
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}
@GetMapping
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,

View File

@@ -36,6 +36,7 @@ public class ReactionController {
Authentication auth) {
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfPost(auth.getName(), postId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
@@ -50,6 +51,7 @@ public class ReactionController {
Authentication auth) {
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
@@ -57,4 +59,17 @@ public class ReactionController {
pointService.awardForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.ok(dto);
}
@PostMapping("/messages/{messageId}/reactions")
public ResponseEntity<ReactionDto> reactToMessage(@PathVariable Long messageId,
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
if (reaction == null) {
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
}
}

View File

@@ -41,7 +41,7 @@ public class RssController {
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
private static final Pattern HTML_IMAGE = Pattern.compile("<img[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
private static final Pattern HTML_IMAGE = Pattern.compile("<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;

View File

@@ -105,6 +105,17 @@ public class UserController {
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/subscribed-posts")
public java.util.List<PostMetaDto> subscribedPosts(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultPostsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService.getSubscribedPosts(user.getUsername()).stream()
.limit(l)
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/replies")
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {

View File

@@ -10,6 +10,7 @@ public class LotteryDto {
private String prizeDescription;
private String prizeIcon;
private int prizeCount;
private int pointCost;
private LocalDateTime startTime;
private LocalDateTime endTime;
private List<AuthorDto> participants;

View File

@@ -2,6 +2,7 @@ package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class MessageDto {
@@ -10,4 +11,6 @@ public class MessageDto {
private UserSummaryDto sender;
private Long conversationId;
private LocalDateTime createdAt;
private MessageDto replyTo;
private List<ReactionDto> reactions;
}

View File

@@ -0,0 +1,17 @@
package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Data
public class PollDto {
private List<String> options;
private Map<Integer, Integer> votes;
private LocalDateTime endTime;
private List<AuthorDto> participants;
private Map<Integer, List<AuthorDto>> optionParticipants;
private boolean multiple;
}

View File

@@ -23,7 +23,11 @@ public class PostRequest {
private String prizeDescription;
private String prizeIcon;
private Integer prizeCount;
private Integer pointCost;
private LocalDateTime startTime;
private LocalDateTime endTime;
// fields for poll posts
private List<String> options;
private Boolean multiple;
}

View File

@@ -31,6 +31,7 @@ public class PostSummaryDto {
private int pointReward;
private PostType type;
private LotteryDto lottery;
private PollDto poll;
private boolean rssExcluded;
private boolean closed;
}

View File

@@ -4,7 +4,7 @@ import com.openisle.model.ReactionType;
import lombok.Data;
/**
* DTO representing a reaction on a post or comment.
* DTO representing a reaction on a post, comment or message.
*/
@Data
public class ReactionDto {
@@ -13,6 +13,7 @@ public class ReactionDto {
private String user;
private Long postId;
private Long commentId;
private Long messageId;
private int reward;
}

View File

@@ -5,18 +5,24 @@ import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.LotteryDto;
import com.openisle.dto.PollDto;
import com.openisle.dto.AuthorDto;
import com.openisle.model.CommentSort;
import com.openisle.model.Post;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
import com.openisle.model.User;
import com.openisle.model.PollVote;
import com.openisle.service.CommentService;
import com.openisle.service.ReactionService;
import com.openisle.service.SubscriptionService;
import com.openisle.repository.PollVoteRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Mapper responsible for converting posts into DTOs. */
@@ -32,6 +38,7 @@ public class PostMapper {
private final UserMapper userMapper;
private final TagMapper tagMapper;
private final CategoryMapper categoryMapper;
private final PollVoteRepository pollVoteRepository;
public PostSummaryDto toSummaryDto(Post post) {
PostSummaryDto dto = new PostSummaryDto();
@@ -86,11 +93,26 @@ public class PostMapper {
l.setPrizeDescription(lp.getPrizeDescription());
l.setPrizeIcon(lp.getPrizeIcon());
l.setPrizeCount(lp.getPrizeCount());
l.setPointCost(lp.getPointCost());
l.setStartTime(lp.getStartTime());
l.setEndTime(lp.getEndTime());
l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
dto.setLottery(l);
}
if (post instanceof PollPost pp) {
PollDto p = new PollDto();
p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime());
p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream()
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
p.setOptionParticipants(optionParticipants);
p.setMultiple(pp.isMultiple());
dto.setPoll(p);
}
}
}

View File

@@ -19,6 +19,9 @@ public class ReactionMapper {
if (reaction.getComment() != null) {
dto.setCommentId(reaction.getComment().getId());
}
if (reaction.getMessage() != null) {
dto.setMessageId(reaction.getMessage().getId());
}
dto.setReward(0);
return dto;
}

View File

@@ -22,7 +22,7 @@ public class Draft {
private String title;
@Column(columnDefinition = "TEXT")
@Column(columnDefinition = "LONGTEXT")
private String content;
@ManyToOne(fetch = FetchType.LAZY, optional = false)

View File

@@ -26,6 +26,9 @@ public class LotteryPost extends Post {
@Column(nullable = false)
private int prizeCount;
@Column(nullable = false)
private int pointCost;
@Column
private LocalDateTime startTime;

View File

@@ -29,6 +29,10 @@ public class Message {
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reply_to_id")
private Message replyTo;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -40,6 +40,12 @@ public enum NotificationType {
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** Someone participated in your poll */
POLL_VOTE,
/** Your poll post has concluded */
POLL_RESULT_OWNER,
/** A poll you participated in has concluded */
POLL_RESULT_PARTICIPANT,
/** Your post was featured */
POST_FEATURED,
/** You were mentioned in a post or comment */

View File

@@ -5,8 +5,12 @@ public enum PointHistoryType {
COMMENT,
POST_LIKED,
COMMENT_LIKED,
POST_LIKE_CANCELLED,
COMMENT_LIKE_CANCELLED,
INVITE,
FEATURE,
SYSTEM_ONLINE,
REDEEM
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD
}

View File

@@ -0,0 +1,43 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.*;
@Entity
@Table(name = "poll_posts")
@Getter
@Setter
@NoArgsConstructor
@PrimaryKeyJoinColumn(name = "post_id")
public class PollPost extends Post {
@ElementCollection
@CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id"))
@Column(name = "option_text")
private List<String> options = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "poll_post_votes", joinColumns = @JoinColumn(name = "post_id"))
@MapKeyColumn(name = "option_index")
@Column(name = "vote_count")
private Map<Integer, Integer> votes = new HashMap<>();
@ManyToMany
@JoinTable(name = "poll_participants",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "user_id"))
private Set<User> participants = new HashSet<>();
@Column
private boolean multiple = false;
@Column
private LocalDateTime endTime;
@Column
private boolean resultAnnounced = false;
}

View File

@@ -0,0 +1,28 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id", "option_index"}))
@Getter
@Setter
@NoArgsConstructor
public class PollVote {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id")
private PollPost post;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "option_index", nullable = false)
private int optionIndex;
}

View File

@@ -31,7 +31,7 @@ public class Post {
@Column(nullable = false)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
@Column(nullable = false, columnDefinition = "LONGTEXT")
private String content;
@CreationTimestamp

View File

@@ -2,5 +2,6 @@ package com.openisle.model;
public enum PostType {
NORMAL,
LOTTERY
LOTTERY,
POLL
}

View File

@@ -7,7 +7,7 @@ import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
/**
* Reaction entity representing a user's reaction to a post or comment.
* Reaction entity representing a user's reaction to a post, comment or message.
*/
@Entity
@Getter
@@ -16,7 +16,8 @@ import org.hibernate.annotations.CreationTimestamp;
@Table(name = "reactions",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "post_id", "type"}),
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"})
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"}),
@UniqueConstraint(columnNames = {"user_id", "message_id", "type"})
})
public class Reaction {
@Id
@@ -39,6 +40,10 @@ public class Reaction {
@JoinColumn(name = "comment_id")
private Comment comment;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "message_id")
private Message message;
@CreationTimestamp
@Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")

View File

@@ -5,6 +5,7 @@ import com.openisle.model.User;
import com.openisle.model.Post;
import com.openisle.model.Comment;
import com.openisle.model.NotificationType;
import com.openisle.model.ReactionType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -29,4 +30,8 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
List<Notification> findByTypeAndFromUser(NotificationType type, User fromUser);
void deleteByTypeAndFromUserAndPost(NotificationType type, User fromUser, Post post);
void deleteByTypeAndFromUserAndPostAndReactionType(NotificationType type, User fromUser, Post post, ReactionType reactionType);
void deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType type, User fromUser, Comment comment, ReactionType reactionType);
}

View File

@@ -4,9 +4,12 @@ import com.openisle.model.PointHistory;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
}

View File

@@ -0,0 +1,13 @@
package com.openisle.repository;
import com.openisle.model.PollPost;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface PollPostRepository extends JpaRepository<PollPost, Long> {
List<PollPost> findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now);
List<PollPost> findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now);
}

View File

@@ -0,0 +1,10 @@
package com.openisle.repository;
import com.openisle.model.PollVote;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PollVoteRepository extends JpaRepository<PollVote, Long> {
List<PollVote> findByPostId(Long postId);
}

View File

@@ -1,6 +1,7 @@
package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.Message;
import com.openisle.model.Post;
import com.openisle.model.Reaction;
import com.openisle.model.User;
@@ -15,8 +16,10 @@ import java.util.Optional;
public interface ReactionRepository extends JpaRepository<Reaction, Long> {
Optional<Reaction> findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type);
Optional<Reaction> findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type);
Optional<Reaction> findByUserAndMessageAndType(User user, Message message, com.openisle.model.ReactionType type);
List<Reaction> findByPost(Post post);
List<Reaction> findByComment(Comment comment);
List<Reaction> findByMessage(Message message);
@Query("SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC")
List<Long> findTopPostIds(@Param("username") String username, Pageable pageable);

View File

@@ -4,14 +4,18 @@ import com.openisle.model.Message;
import com.openisle.model.MessageConversation;
import com.openisle.model.MessageParticipant;
import com.openisle.model.User;
import com.openisle.model.Reaction;
import com.openisle.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.dto.ConversationDetailDto;
import com.openisle.dto.ConversationDto;
import com.openisle.dto.MessageDto;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.mapper.ReactionMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@@ -34,9 +38,11 @@ public class MessageService {
private final MessageParticipantRepository participantRepository;
private final UserRepository userRepository;
private final SimpMessagingTemplate messagingTemplate;
private final ReactionRepository reactionRepository;
private final ReactionMapper reactionMapper;
@Transactional
public Message sendMessage(Long senderId, Long recipientId, String content) {
public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) {
log.info("Attempting to send message from user {} to user {}", senderId, recipientId);
User sender = userRepository.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
@@ -51,6 +57,11 @@ public class MessageService {
message.setConversation(conversation);
message.setSender(sender);
message.setContent(content);
if (replyToId != null) {
Message replyTo = messageRepository.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
message = messageRepository.save(message);
log.info("Message saved with ID: {}", message.getId());
@@ -83,7 +94,7 @@ public class MessageService {
}
@Transactional
public Message sendMessageToConversation(Long senderId, Long conversationId, String content) {
public Message sendMessageToConversation(Long senderId, Long conversationId, String content, Long replyToId) {
User sender = userRepository.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
MessageConversation conversation = conversationRepository.findById(conversationId)
@@ -102,6 +113,11 @@ public class MessageService {
message.setConversation(conversation);
message.setSender(sender);
message.setContent(content);
if (replyToId != null) {
Message replyTo = messageRepository.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
message = messageRepository.save(message);
conversation.setLastMessage(message);
@@ -128,7 +144,7 @@ public class MessageService {
return message;
}
private MessageDto toDto(Message message) {
public MessageDto toDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
@@ -141,6 +157,25 @@ public class MessageService {
userSummaryDto.setAvatar(message.getSender().getAvatar());
dto.setSender(userSummaryDto);
if (message.getReplyTo() != null) {
Message reply = message.getReplyTo();
MessageDto replyDto = new MessageDto();
replyDto.setId(reply.getId());
replyDto.setContent(reply.getContent());
UserSummaryDto replySender = new UserSummaryDto();
replySender.setId(reply.getSender().getId());
replySender.setUsername(reply.getSender().getUsername());
replySender.setAvatar(reply.getSender().getAvatar());
replyDto.setSender(replySender);
dto.setReplyTo(replyDto);
}
java.util.List<Reaction> reactions = reactionRepository.findByMessage(message);
java.util.List<ReactionDto> reactionDtos = reactions.stream()
.map(reactionMapper::toDto)
.collect(Collectors.toList());
dto.setReactions(reactionDtos);
return dto;
}

View File

@@ -114,6 +114,14 @@ public class NotificationService {
return n;
}
public void deleteReactionNotification(User fromUser, Post post, Comment comment, ReactionType reactionType) {
if (post != null) {
notificationRepository.deleteByTypeAndFromUserAndPostAndReactionType(NotificationType.REACTION, fromUser, post, reactionType);
} else if (comment != null) {
notificationRepository.deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType.REACTION, fromUser, comment, reactionType);
}
}
/**
* Create notifications for all admins when a user submits a register request.
* Old register request notifications from the same applicant are removed first.

View File

@@ -2,10 +2,15 @@ package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.*;
import com.openisle.exception.FieldException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -39,6 +44,17 @@ public class PointService {
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
}
public void processLotteryJoin(User participant, LotteryPost post) {
int cost = post.getPointCost();
if (cost > 0) {
if (participant.getPoint() < cost) {
throw new FieldException("point", "积分不足");
}
addPoint(participant, -cost, PointHistoryType.LOTTERY_JOIN, post, null, post.getAuthor());
addPoint(post.getAuthor(), cost, PointHistoryType.LOTTERY_REWARD, post, null, participant);
}
}
private PointLog getTodayLog(User user) {
LocalDate today = LocalDate.now();
return pointLogRepository.findByUserAndLogDate(user, today)
@@ -134,6 +150,16 @@ public class PointService {
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
}
public int deductForReactionOfPost(String reactionerName, Long postId) {
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (poster.getId().equals(reactioner.getId())) {
return 0;
}
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, -10, PointHistoryType.POST_LIKE_CANCELLED, post, null, reactioner);
}
// 考虑点赞者和评论者是同一个的情况
public int awardForReactionOfComment(String reactionerName, Long commentId) {
// 根据帖子id找到评论者
@@ -153,6 +179,17 @@ public class PointService {
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
}
public int deductForReactionOfComment(String reactionerName, Long commentId) {
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (commenter.getId().equals(reactioner.getId())) {
return 0;
}
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(commenter, -10, PointHistoryType.COMMENT_LIKE_CANCELLED, post, comment, reactioner);
}
public java.util.List<PointHistory> listHistory(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
if (pointHistoryRepository.countByUser(user) == 0) {
@@ -161,4 +198,25 @@ public class PointService {
return pointHistoryRepository.findByUserOrderByIdDesc(user);
}
public List<Map<String, Object>> trend(String userName, int days) {
if (days < 1) days = 1;
User user = userRepository.findByUsername(userName).orElseThrow();
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var histories = pointHistoryRepository.findByUserAndCreatedAtAfterOrderByCreatedAtDesc(
user, start.atStartOfDay());
int idx = 0;
int balance = user.getPoint();
List<Map<String, Object>> result = new ArrayList<>();
for (LocalDate day = end; !day.isBefore(start); day = day.minusDays(1)) {
result.add(Map.of("date", day.toString(), "value", balance));
while (idx < histories.size() && histories.get(idx).getCreatedAt().toLocalDate().isEqual(day)) {
balance -= histories.get(idx).getAmount();
idx++;
}
}
Collections.reverse(result);
return result;
}
}

View File

@@ -9,8 +9,11 @@ import com.openisle.model.Category;
import com.openisle.model.Comment;
import com.openisle.model.NotificationType;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
import com.openisle.model.PollVote;
import com.openisle.repository.PostRepository;
import com.openisle.repository.LotteryPostRepository;
import com.openisle.repository.PollPostRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository;
@@ -20,6 +23,7 @@ import com.openisle.repository.CommentRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.PostSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PollVoteRepository;
import com.openisle.model.Role;
import com.openisle.exception.RateLimitException;
import lombok.extern.slf4j.Slf4j;
@@ -54,6 +58,8 @@ public class PostService {
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository;
private final PollPostRepository pollPostRepository;
private final PollVoteRepository pollVoteRepository;
private PublishMode publishMode;
private final NotificationService notificationService;
private final SubscriptionService subscriptionService;
@@ -78,6 +84,8 @@ public class PostService {
CategoryRepository categoryRepository,
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
CommentService commentService,
@@ -97,6 +105,8 @@ public class PostService {
this.categoryRepository = categoryRepository;
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
this.commentService = commentService;
@@ -125,6 +135,15 @@ public class PostService {
for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) {
applicationContext.getBean(PostService.class).finalizeLottery(lp.getId());
}
for (PollPost pp : pollPostRepository.findByEndTimeAfterAndResultAnnouncedFalse(now)) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(pp.getId(), future);
}
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
}
}
public PublishMode getPublishMode() {
@@ -164,8 +183,11 @@ public class PostService {
String prizeDescription,
String prizeIcon,
Integer prizeCount,
Integer pointCost,
LocalDateTime startTime,
LocalDateTime endTime) {
LocalDateTime endTime,
java.util.List<String> options,
Boolean multiple) {
long recent = postRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(5));
if (recent >= 1) {
@@ -188,13 +210,26 @@ public class PostService {
PostType actualType = type != null ? type : PostType.NORMAL;
Post post;
if (actualType == PostType.LOTTERY) {
if (pointCost != null && (pointCost < 0 || pointCost > 100)) {
throw new IllegalArgumentException("pointCost must be between 0 and 100");
}
LotteryPost lp = new LotteryPost();
lp.setPrizeDescription(prizeDescription);
lp.setPrizeIcon(prizeIcon);
lp.setPrizeCount(prizeCount != null ? prizeCount : 0);
lp.setPointCost(pointCost != null ? pointCost : 0);
lp.setStartTime(startTime);
lp.setEndTime(endTime);
post = lp;
} else if (actualType == PostType.POLL) {
if (options == null || options.size() < 2) {
throw new IllegalArgumentException("At least two options required");
}
PollPost pp = new PollPost();
pp.setOptions(options);
pp.setEndTime(endTime);
pp.setMultiple(multiple != null && multiple);
post = pp;
} else {
post = new Post();
}
@@ -207,6 +242,8 @@ public class PostService {
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
if (post instanceof LotteryPost) {
post = lotteryPostRepository.save((LotteryPost) post);
} else if (post instanceof PollPost) {
post = pollPostRepository.save((PollPost) post);
} else {
post = postRepository.save(post);
}
@@ -241,6 +278,11 @@ public class PostService {
() -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()),
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(lp.getId(), future);
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(pp.getId(), future);
}
return post;
}
@@ -250,8 +292,70 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (post.getParticipants().add(user)) {
pointService.processLotteryJoin(user, post);
lotteryPostRepository.save(post);
}
}
public PollPost getPoll(Long postId) {
return pollPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
}
@Transactional
public PollPost votePoll(Long postId, String username, java.util.List<Integer> optionIndices) {
PollPost post = pollPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
throw new IllegalStateException("Poll has ended");
}
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (post.getParticipants().contains(user)) {
throw new IllegalArgumentException("User already voted");
}
if (optionIndices == null || optionIndices.isEmpty()) {
throw new IllegalArgumentException("No options selected");
}
java.util.Set<Integer> unique = new java.util.HashSet<>(optionIndices);
for (int optionIndex : unique) {
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
throw new IllegalArgumentException("Invalid option");
}
}
post.getParticipants().add(user);
lotteryPostRepository.save(post);
for (int optionIndex : unique) {
post.getVotes().merge(optionIndex, 1, Integer::sum);
PollVote vote = new PollVote();
vote.setPost(post);
vote.setUser(user);
vote.setOptionIndex(optionIndex);
pollVoteRepository.save(vote);
}
PollPost saved = pollPostRepository.save(post);
if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null);
}
return saved;
}
@Transactional
public void finalizePoll(Long postId) {
scheduledFinalizations.remove(postId);
pollPostRepository.findById(postId).ifPresent(pp -> {
if (pp.isResultAnnounced()) {
return;
}
pp.setResultAnnounced(true);
pollPostRepository.save(pp);
if (pp.getAuthor() != null) {
notificationService.createNotification(pp.getAuthor(), NotificationType.POLL_RESULT_OWNER, pp, null, null, null, null, null);
}
for (User participant : pp.getParticipants()) {
notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null);
}
});
}
@Transactional

View File

@@ -6,10 +6,12 @@ import com.openisle.model.Reaction;
import com.openisle.model.ReactionType;
import com.openisle.model.User;
import com.openisle.model.NotificationType;
import com.openisle.model.Message;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.service.NotificationService;
import com.openisle.service.EmailSender;
import lombok.RequiredArgsConstructor;
@@ -24,6 +26,7 @@ public class ReactionService {
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final MessageRepository messageRepository;
private final NotificationService notificationService;
private final EmailSender emailSender;
@@ -39,6 +42,7 @@ public class ReactionService {
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndPostAndType(user, post, type);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, post, null, type);
reactionRepository.delete(existing.get());
return null;
}
@@ -62,6 +66,7 @@ public class ReactionService {
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndCommentAndType(user, comment, type);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, null, comment, type);
reactionRepository.delete(existing.get());
return null;
}
@@ -77,6 +82,26 @@ public class ReactionService {
return reaction;
}
@Transactional
public Reaction reactToMessage(String username, Long messageId, ReactionType type) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Message message = messageRepository.findById(messageId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndMessageAndType(user, message, type);
if (existing.isPresent()) {
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
return reaction;
}
public java.util.List<Reaction> getReactionsForPost(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));

View File

@@ -107,6 +107,11 @@ public class SubscriptionService {
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
}
public List<Post> getSubscribedPosts(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList();
}
public long countSubscribers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();

View File

@@ -0,0 +1 @@
ALTER TABLE lottery_posts ADD COLUMN point_cost INT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,65 @@
package com.openisle.controller;
import com.openisle.config.CustomAccessDeniedHandler;
import com.openisle.config.SecurityConfig;
import com.openisle.service.PointService;
import com.openisle.mapper.PointHistoryMapper;
import com.openisle.service.JwtService;
import com.openisle.repository.UserRepository;
import com.openisle.model.User;
import com.openisle.model.Role;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PointHistoryController.class)
@AutoConfigureMockMvc
@Import({SecurityConfig.class, CustomAccessDeniedHandler.class})
class PointHistoryControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private JwtService jwtService;
@MockBean
private UserRepository userRepository;
@MockBean
private PointService pointService;
@MockBean
private PointHistoryMapper pointHistoryMapper;
@Test
void trendReturnsSeries() throws Exception {
Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user");
User user = new User();
user.setUsername("user");
user.setPassword("p");
user.setEmail("u@example.com");
user.setRole(Role.USER);
Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user));
List<Map<String, Object>> data = List.of(
Map.of("date", java.time.LocalDate.now().minusDays(1).toString(), "value", 100),
Map.of("date", java.time.LocalDate.now().toString(), "value", 110)
);
Mockito.when(pointService.trend(Mockito.eq("user"), Mockito.anyInt())).thenReturn(data);
mockMvc.perform(get("/api/point-histories/trend").param("days", "2")
.header("Authorization", "Bearer token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].value").value(100))
.andExpect(jsonPath("$[1].value").value(110));
}
}

View File

@@ -76,7 +76,7 @@ class PostControllerTest {
post.setTags(Set.of(tag));
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
when(postService.viewPost(eq(1L), any())).thenReturn(post);
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
@@ -187,7 +187,7 @@ class PostControllerTest {
.andExpect(status().isBadRequest());
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
any(), any(), any(), any(), any(), any());
any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test

View File

@@ -5,6 +5,7 @@ import com.openisle.model.Post;
import com.openisle.model.Reaction;
import com.openisle.model.ReactionType;
import com.openisle.model.User;
import com.openisle.model.Message;
import com.openisle.service.ReactionService;
import com.openisle.service.LevelService;
import com.openisle.mapper.ReactionMapper;
@@ -78,6 +79,27 @@ class ReactionControllerTest {
.andExpect(jsonPath("$.commentId").value(2));
}
@Test
void reactToMessage() throws Exception {
User user = new User();
user.setUsername("u3");
Message message = new Message();
message.setId(3L);
Reaction reaction = new Reaction();
reaction.setId(3L);
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(ReactionType.LIKE);
Mockito.when(reactionService.reactToMessage(eq("u3"), eq(3L), eq(ReactionType.LIKE))).thenReturn(reaction);
mockMvc.perform(post("/api/messages/3/reactions")
.contentType("application/json")
.content("{\"type\":\"LIKE\"}")
.principal(new UsernamePasswordAuthenticationToken("u3", "p")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.messageId").value(3));
}
@Test
void listReactionTypes() throws Exception {
mockMvc.perform(get("/api/reaction-types"))

View File

@@ -136,6 +136,30 @@ class UserControllerTest {
.andExpect(jsonPath("$[0].title").value("hello"));
}
@Test
void listSubscribedPosts() throws Exception {
User user = new User();
user.setUsername("bob");
com.openisle.model.Category cat = new com.openisle.model.Category();
cat.setName("tech");
com.openisle.model.Post post = new com.openisle.model.Post();
post.setId(6L);
post.setTitle("fav");
post.setCreatedAt(java.time.LocalDateTime.now());
post.setCategory(cat);
post.setAuthor(user);
Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user));
Mockito.when(subscriptionService.getSubscribedPosts("bob")).thenReturn(java.util.List.of(post));
PostMetaDto meta = new PostMetaDto();
meta.setId(6L);
meta.setTitle("fav");
Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta);
mockMvc.perform(get("/api/users/bob/subscribed-posts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("fav"));
}
@Test
void listUserReplies() throws Exception {
User user = new User();

View File

@@ -146,7 +146,7 @@ class PostServiceTest {
assertThrows(RateLimitException.class,
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null));
null, null, null, null, null, null, null, null, null));
}
@Test

View File

@@ -15,9 +15,10 @@ class ReactionServiceTest {
UserRepository userRepo = mock(UserRepository.class);
PostRepository postRepo = mock(PostRepository.class);
CommentRepository commentRepo = mock(CommentRepository.class);
MessageRepository messageRepo = mock(MessageRepository.class);
NotificationService notif = mock(NotificationService.class);
EmailSender email = mock(EmailSender.class);
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, notif, email);
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, messageRepo, notif, email);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();

View File

@@ -1,6 +1,6 @@
<template>
<div id="app">
<div class="header-container">
<div v-if="!isFloatMode" class="header-container">
<HeaderComponent
ref="header"
@toggle-menu="menuVisible = !menuVisible"
@@ -9,20 +9,28 @@
</div>
<div class="main-container">
<div class="menu-container" v-click-outside="handleMenuOutside">
<div v-if="!isFloatMode" class="menu-container" v-click-outside="handleMenuOutside">
<MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" />
</div>
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
<div
class="content"
:class="{ 'menu-open': menuVisible && !hideMenu && !isFloatMode }"
:style="isFloatMode ? { paddingTop: '0px', minHeight: '100vh' } : {}"
>
<NuxtPage keepalive />
</div>
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
<div
v-if="showNewPostIcon && isMobile && !isFloatMode"
class="app-new-post-icon"
@click="goToNewPost"
>
<i class="fas fa-edit"></i>
</div>
</div>
<GlobalPopups />
<ConfirmDialog />
<ChatFloating />
<MessageFloatWindow v-if="!isFloatMode" />
</div>
</template>
@@ -31,7 +39,7 @@ import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue'
import ConfirmDialog from '~/components/ConfirmDialog.vue'
import ChatFloating from '~/components/ChatFloating.vue'
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
import { useIsMobile } from '~/utils/screen'
const isMobile = useIsMobile()
@@ -54,6 +62,7 @@ const hideMenu = computed(() => {
})
const header = useTemplateRef('header')
const isFloatMode = computed(() => useRoute().query.float !== undefined)
onMounted(() => {
if (typeof window !== 'undefined') {
@@ -129,7 +138,7 @@ const goToNewPost = () => {
height: 60px;
border-radius: 50%;
position: fixed;
bottom: 40px;
bottom: 70px;
right: 20px;
font-size: 20px;
cursor: pointer;

View File

@@ -18,8 +18,8 @@
--background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray;
--normal-border-color: lightgray;
--menu-selected-background-color: rgba(228, 228, 228, 0.884);
--menu-text-color: black;
--menu-selected-background-color: rgba(242, 242, 242, 0.884);
--menu-text-color: rgb(99, 99, 99);
--scroller-background-color: rgba(130, 175, 180, 0.5);
/* --normal-background-color: rgb(241, 241, 241); */
--normal-background-color: white;
@@ -27,13 +27,14 @@
--code-highlight-background-color: rgb(241, 241, 241);
--login-background-color: rgb(248, 248, 248);
--login-background-color-hover: #e0e0e0;
--text-color: black;
--text-color: rgb(70, 70, 70);
--blockquote-text-color: #6a737d;
--menu-width: 200px;
--page-max-width: 1400px;
--page-max-width-mobile: 900px;
--article-info-background-color: #f0f0f0;
--activity-card-background-color: #fafafa;
--poll-option-button-background-color: rgb(218, 218, 218);
}
[data-theme='dark'] {
@@ -50,7 +51,7 @@
--menu-border-color: #555;
--normal-border-color: #555;
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
--menu-text-color: white;
--menu-text-color: rgb(173, 173, 173);
/* --normal-background-color: #000000; */
--normal-background-color: #333;
--lottery-background-color: #4e4e4e;
@@ -61,6 +62,7 @@
--blockquote-text-color: #999;
--article-info-background-color: #747373;
--activity-card-background-color: #585858;
--poll-option-button-background-color: #3a3a3a;
}
:root[data-frosted='off'] {
@@ -75,7 +77,7 @@
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
font-family: 'WenQuanYi Micro Hei', 'Helvetica Neue', Arial, sans-serif;
background-color: var(--normal-background-color);
color: var(--text-color);
/* 禁止滚动 */
@@ -91,7 +93,7 @@ body {
.vditor-toolbar--pin {
top: calc(var(--header-height) + 1px) !important;
z-index: 2000;
z-index: 20;
}
.vditor-panel {
@@ -139,6 +141,10 @@ body {
margin-bottom: 0.8em;
}
.info-content-text video {
max-width: 100%;
}
.info-content-text {
word-break: break-word;
max-width: 100%;
@@ -158,6 +164,7 @@ body {
border-radius: 4px;
line-height: 1.5;
position: relative;
white-space: break-spaces;
}
.info-content-text pre .line-numbers {
@@ -184,7 +191,6 @@ body {
font-family: 'Maple Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: break-spaces;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
}

View File

@@ -9,7 +9,7 @@
]"
@click="selectMedal(medal)"
>
<img
<BaseImage
:src="medal.icon"
:alt="medal.title"
:class="['achievements-list-item-icon', { not_completed: !medal.completed }]"

View File

@@ -1,7 +1,7 @@
<template>
<BasePopup :visible="visible" @close="close">
<div class="activity-popup">
<img v-if="icon" :src="icon" class="activity-popup-icon" alt="activity icon" />
<BaseImage v-if="icon" :src="icon" class="activity-popup-icon" alt="activity icon" />
<div class="activity-popup-text">{{ text }}</div>
<div class="activity-popup-actions">
<div class="activity-popup-button" @click="gotoActivity">立即前往</div>

View File

@@ -1,7 +1,7 @@
<template>
<div class="article-category-container" v-if="category">
<div class="article-info-item" @click="gotoCategory">
<img
<BaseImage
v-if="category.smallIcon"
class="article-info-item-img"
:src="category.smallIcon"

View File

@@ -6,7 +6,7 @@
:key="tag.id || tag.name"
@click="gotoTag(tag)"
>
<img
<BaseImage
v-if="tag.smallIcon"
class="article-info-item-img"
:src="tag.smallIcon"

View File

@@ -0,0 +1,66 @@
<template>
<NuxtImg
v-bind="passAttrs"
:src="src"
:alt="alt"
loading="lazy"
:placeholder="placeholder"
placeholder-class="base-image-ph"
@load="onLoad"
@error="onError"
:class="['base-image', passAttrs.class, { 'is-loaded': loaded }]"
/>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useAttrs } from 'vue'
const props = defineProps({
src: { type: String, required: true },
alt: { type: String, default: '' },
})
const attrs = useAttrs()
const passAttrs = computed(() => {
const { placeholder, ...rest } = attrs
return rest
})
const loaded = ref(false)
const img = useImage()
const placeholder = computed(() => {
if (!props.src) return undefined
return img(props.src, { w: 16, h: 16, f: 'webp', q: 20, blur: 1 })
})
function onLoad() {
loaded.value = true
}
function onError() {
loaded.value = true
}
</script>
<style scoped>
.base-image {
transition:
filter 0.35s ease,
transform 0.35s ease,
opacity 0.35s ease;
opacity: 0.92;
}
.base-image-ph {
filter: blur(20px);
transform: scale(0.5);
}
.base-image.is-loaded {
/* Allow filters from parent classes (e.g. grayscale for unfinished medals) */
transform: none;
opacity: 1;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="base-tabs">
<div class="base-tabs-header">
<div class="base-tabs-items">
<div
v-for="tab in tabs"
:key="tab.key"
:class="['base-tabs-item', { selected: modelValue === tab.key }]"
@click="$emit('update:modelValue', tab.key)"
>
<i v-if="tab.icon" :class="tab.icon"></i>
<div class="base-tabs-item-label">{{ tab.label }}</div>
</div>
</div>
<div class="base-tabs-header-right">
<slot name="right"></slot>
</div>
</div>
<div class="base-tabs-content" @touchstart="onTouchStart" @touchend="onTouchEnd">
<slot></slot>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: String, required: true },
tabs: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
let touchStartX = 0
function onTouchStart(e) {
touchStartX = e.touches[0].clientX
}
function onTouchEnd(e) {
const diffX = e.changedTouches[0].clientX - touchStartX
if (Math.abs(diffX) > 50) {
const index = props.tabs.findIndex((t) => t.key === props.modelValue)
if (diffX < 0 && index < props.tabs.length - 1) {
emit('update:modelValue', props.tabs[index + 1].key)
} else if (diffX > 0 && index > 0) {
emit('update:modelValue', props.tabs[index - 1].key)
}
}
}
</script>
<style scoped>
.base-tabs-header {
display: flex;
border-bottom: 1px solid var(--normal-border-color);
align-items: center;
flex-direction: row;
}
.base-tabs-items {
display: flex;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
}
.base-tabs-item {
padding: 10px 20px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
}
.base-tabs-item i {
margin-right: 6px;
}
.base-tabs-item.selected {
color: var(--primary-color);
border-bottom: 2px solid var(--primary-color);
}
.base-tabs-header-right {
display: flex;
flex-shrink: 0;
}
.base-tabs-content {
width: 100%;
}
</style>

View File

@@ -6,9 +6,9 @@
:class="{ clickable: !!item.iconClick }"
@click="item.iconClick && item.iconClick()"
>
<img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
<BaseImage v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
<i v-else-if="item.icon" :class="item.icon"></i>
<img v-else-if="item.emoji" :src="item.emoji" class="timeline-emoji" alt="emoji" />
<BaseImage v-else-if="item.emoji" :src="item.emoji" class="timeline-emoji" alt="emoji" />
</div>
<div class="timeline-content">
<slot name="item" :item="item">{{ item.content }}</slot>

View File

@@ -9,7 +9,7 @@
<div class="option-container">
<div class="option-main">
<template v-if="option.icon">
<img
<BaseImage
v-if="isImageIcon(option.icon)"
:src="option.icon"
class="option-icon"

View File

@@ -1,56 +0,0 @@
<template>
<div v-if="chatFloating" class="chat-floating">
<iframe :src="iframeSrc" class="chat-frame"></iframe>
</div>
</template>
<script setup>
import { computed } from 'vue'
const chatFloating = useState('chatFloating', () => false)
const chatPath = useState('chatPath', () => '/message-box')
const iframeSrc = computed(() =>
chatPath.value.includes('?') ? `${chatPath.value}&float=1` : `${chatPath.value}?float=1`,
)
if (process.client) {
window.addEventListener('message', (event) => {
if (event.data?.type === 'maximize-chat') {
chatFloating.value = false
navigateTo(event.data.path || chatPath.value)
}
})
}
</script>
<style scoped>
.chat-floating {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px;
height: 70vh;
max-height: 600px;
background: var(--background-color);
border: 1px solid var(--normal-border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 2000;
display: flex;
flex-direction: column;
}
.chat-frame {
width: 100%;
height: 100%;
border: none;
}
@media (max-width: 500px) {
.chat-floating {
right: 0;
width: 100%;
height: 60vh;
}
}
</style>

View File

@@ -8,7 +8,7 @@
>
<!-- <div class="user-avatar-container">
<div class="user-avatar-item">
<img class="user-avatar-item-img" :src="comment.avatar" alt="avatar" />
<BaseImage class="user-avatar-item-img" :src="comment.avatar" alt="avatar" />
</div>
</div> -->
<div class="info-content">
@@ -23,9 +23,13 @@
>{{ getMedalTitle(comment.medal) }}</NuxtLink
>
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2">
<span v-if="level >= 2" class="reply-item">
<i class="fas fa-reply reply-icon"></i>
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
<span class="reply-info">
<BaseImage class="reply-avatar" :src="comment.parentUserAvatar || '/default-avatar.svg'" alt="avatar"
@click="comment.parentUserClick && comment.parentUserClick()" />
<span class="reply-user-name">{{ comment.parentUserName }}</span>
</span>
</span>
<div class="post-time">{{ comment.time }}</div>
</div>
@@ -250,6 +254,7 @@ const submitReply = async (parentUserName, text, clear) => {
medal: data.author.displayMedal,
text: data.content,
parentUserName: parentUserName,
parentUserAvatar: props.comment.avatar,
reactions: [],
reply: (data.replies || []).map((r) => ({
id: r.id,
@@ -376,7 +381,22 @@ const handleContentClick = (e) => {
justify-content: space-between;
}
.reply-item, .reply-info {
display: inline-flex;
flex-direction: row;
align-items: center;
}
.reply-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 5px;
cursor: pointer;
}
.reply-icon {
color: var(--primary-color);
margin-right: 10px;
margin-left: 10px;
opacity: 0.5;

View File

@@ -13,7 +13,7 @@
<template v-for="(label, idx) in selectedLabels" :key="label.id">
<div class="selected-label">
<template v-if="label.icon">
<img
<BaseImage
v-if="isImageIcon(label.icon)"
:src="label.icon"
class="option-icon"
@@ -32,7 +32,7 @@
<span v-if="selectedLabels.length">
<div class="selected-label">
<template v-if="selectedLabels[0].icon">
<img
<BaseImage
v-if="isImageIcon(selectedLabels[0].icon)"
:src="selectedLabels[0].icon"
class="option-icon"
@@ -69,7 +69,12 @@
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<i v-else :class="['option-icon', o.icon]"></i>
</template>
<span>{{ o.name }}</span>
@@ -100,7 +105,12 @@
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<i v-else :class="['option-icon', o.icon]"></i>
</template>
<span>{{ o.name }}</span>

View File

@@ -4,7 +4,7 @@
<div class="header-content-left">
<div v-if="showMenuBtn" class="menu-btn-wrapper">
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i>
<i class="fas fa-bars micon"></i>
</button>
<span
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
@@ -12,7 +12,7 @@
></span>
</div>
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img
<BaseImage
alt="OpenIsle"
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
width="60"
@@ -75,7 +75,6 @@
</div>
</div>
</ClientOnly>
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
</div>
</header>
@@ -149,13 +148,14 @@ const copyInviteLink = async () => {
if (res.ok) {
const data = await res.json()
const inviteLink = data.token ? `${WEBSITE_BASE_URL}/signup?invite_token=${data.token}` : ''
/**
/**
* navigator.clipboard在webkit中有点奇怪的行为
* https://stackoverflow.com/questions/62327358/javascript-clipboard-api-safari-ios-notallowederror-message
* https://webkit.org/blog/10247/new-webkit-features-in-safari-13-1/
*/
*/
setTimeout(() => {
navigator.clipboard.writeText(inviteLink)
navigator.clipboard
.writeText(inviteLink)
.then(() => {
toast.success('邀请链接已复制')
})
@@ -318,6 +318,10 @@ onMounted(async () => {
gap: 20px;
}
.micon {
margin-left: 10px;
}
.menu-btn {
font-size: 24px;
background: none;
@@ -370,6 +374,7 @@ onMounted(async () => {
display: flex;
align-items: center;
cursor: pointer;
margin-right: 10px;
}
.avatar-img {

View File

@@ -0,0 +1,189 @@
<template>
<div class="lottery-section">
<AvatarCropper
:src="data.tempPrizeIcon"
:show="data.showPrizeCropper"
@close="data.showPrizeCropper = false"
@crop="onPrizeCropped"
/>
<div class="prize-row">
<span class="prize-row-title">奖品图片</span>
<label class="prize-container">
<BaseImage v-if="data.prizeIcon" :src="data.prizeIcon" class="prize-preview" alt="prize" />
<i v-else class="fa-solid fa-image default-prize-icon"></i>
<div class="prize-overlay">上传奖品图片</div>
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
</label>
</div>
<div class="prize-name-row">
<span class="prize-row-title">奖品描述</span>
<BaseInput v-model="data.prizeDescription" placeholder="奖品描述" />
</div>
<div class="prize-count-row">
<span class="prize-row-title">奖品数量</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="data.prizeCount"
min="1"
/>
</div>
</div>
<div class="prize-point-row">
<span class="prize-row-title">参与所需积分</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="data.pointCost"
min="0"
max="100"
/>
</div>
</div>
<div class="prize-time-row">
<span class="prize-row-title">抽奖结束时间</span>
<client-only>
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
</div>
</template>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import FlatPickr from 'vue-flatpickr-component'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseImage from '~/components/BaseImage.vue'
import BaseInput from '~/components/BaseInput.vue'
import { watch } from 'vue'
const props = defineProps({
data: {
type: Object,
required: true,
},
})
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const onPrizeIconChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
props.data.tempPrizeIcon = reader.result
props.data.showPrizeCropper = true
}
reader.readAsDataURL(file)
}
}
const onPrizeCropped = ({ file, url }) => {
props.data.prizeIconFile = file
props.data.prizeIcon = url
}
watch(
() => props.data.prizeCount,
(val) => {
if (!val || val < 1) props.data.prizeCount = 1
},
)
watch(
() => props.data.pointCost,
(val) => {
if (val === undefined || val === null || val < 0) props.data.pointCost = 0
if (val > 100) props.data.pointCost = 100
},
)
</script>
<style scoped>
.lottery-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.prize-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.prize-row,
.prize-name-row,
.prize-count-row,
.prize-point-row,
.prize-time-row {
display: flex;
flex-direction: column;
}
.prize-container {
position: relative;
width: 100px;
height: 100px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
background-color: var(--lottery-background-color);
display: flex;
align-items: center;
justify-content: center;
}
.default-prize-icon {
font-size: 30px;
opacity: 0.1;
color: var(--text-color);
}
.prize-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.prize-input {
display: none;
}
.prize-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.prize-container:hover .prize-overlay {
opacity: 1;
}
.prize-count-input {
display: flex;
align-items: center;
}
.prize-count-input-field {
width: 50px;
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
font-size: 16px;
color: var(--text-color);
background-color: var(--lottery-background-color);
}
.time-picker {
max-width: 200px;
height: 30px;
background-color: var(--lottery-background-color);
border-radius: 5px;
border: 1px solid var(--border-color);
}
</style>

View File

@@ -4,7 +4,7 @@
<div class="medal-popup-title">恭喜你获得以下勋章</div>
<div class="medal-popup-list">
<div v-for="medal in medals" :key="medal.type" class="medal-popup-item">
<img :src="medal.icon" :alt="medal.title" class="medal-popup-item-icon" />
<BaseImage :src="medal.icon" :alt="medal.title" class="medal-popup-item-icon" />
<div class="medal-popup-item-title">{{ medal.title }}</div>
</div>
</div>

View File

@@ -88,7 +88,7 @@
@click="gotoCategory(c)"
>
<template v-if="c.smallIcon || c.icon">
<img
<BaseImage
v-if="isImageIcon(c.smallIcon || c.icon)"
:src="c.smallIcon || c.icon"
class="section-item-icon"
@@ -114,7 +114,7 @@
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
<img
<BaseImage
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
@@ -279,12 +279,29 @@ const gotoTag = (t) => {
padding: 10px 10px 0 10px;
}
.menu-content::-webkit-scrollbar {
display: none;
}
.menu-content {
-ms-overflow-style: none;
scrollbar-width: none;
}
.menu-item-container {
border-bottom: 1px solid var(--menu-border-color);
}
.menu-item:last-child {
margin-bottom: 5px;
}
/* .menu-item-container { */
/**/
/* } */
.menu-item {
padding: 4px 10px;
padding: 6px 12px;
text-decoration: none;
color: var(--menu-text-color);
border-radius: 10px;
@@ -298,7 +315,7 @@ const gotoTag = (t) => {
}
.menu-item-text {
font-size: 16px;
font-size: 14px;
text-decoration: none;
color: var(--menu-text-color);
}
@@ -352,16 +369,17 @@ const gotoTag = (t) => {
}
.menu-section {
margin-top: 10px;
border-bottom: 1px solid var(--menu-border-color);
padding-bottom: 5px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: bold;
opacity: 0.5;
padding: 4px 10px;
font-size: 14px;
padding: 6px 12px 0 12px;
color: var(--menu-text-color);
cursor: pointer;
}
@@ -373,7 +391,7 @@ const gotoTag = (t) => {
}
.section-item {
padding: 4px 10px;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 5px;
@@ -393,6 +411,8 @@ const gotoTag = (t) => {
}
.section-item-text {
font-size: 14px;
text-decoration: none;
color: var(--menu-text-color);
}

View File

@@ -0,0 +1,115 @@
<template>
<div v-if="floatRoute" class="message-float-window" :style="{ height: floatHeight }">
<iframe :src="iframeSrc" frameborder="0" ref="iframeRef" @load="injectBaseTag"></iframe>
<div class="float-actions">
<i
class="fas fa-chevron-down"
v-if="floatHeight !== MINI_HEIGHT"
title="收起至 100px"
@click="collapseToMini"
></i>
<i
class="fas fa-chevron-up"
v-if="floatHeight !== DEFAULT_HEIGHT"
title="回弹至 60vh"
@click="reboundToDefault"
></i>
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
<i class="fas fa-times" title="关闭" @click="close"></i>
</div>
</div>
</template>
<script setup>
const floatRoute = useState('messageFloatRoute')
const DEFAULT_HEIGHT = '60vh'
const MINI_HEIGHT = '45px'
const floatHeight = ref(DEFAULT_HEIGHT)
const iframeRef = ref(null)
const iframeSrc = computed(() => {
if (!floatRoute.value) return ''
return floatRoute.value + (floatRoute.value.includes('?') ? '&' : '?') + 'float=1'
})
function collapseToMini() {
floatHeight.value = MINI_HEIGHT
}
function reboundToDefault() {
floatHeight.value = DEFAULT_HEIGHT
}
function expand() {
if (!floatRoute.value) return
const target = floatRoute.value
floatRoute.value = null
navigateTo(target)
}
function close() {
floatRoute.value = null
}
function injectBaseTag() {
if (!iframeRef.value) return
const iframeDoc = iframeRef.value.contentDocument || iframeRef.value.contentWindow.document
if (iframeDoc && !iframeDoc.querySelector('base')) {
const base = iframeDoc.createElement('base')
base.target = '_top'
iframeDoc.head.appendChild(base)
}
}
watch(
() => floatRoute.value,
(v) => {
if (v) floatHeight.value = DEFAULT_HEIGHT
},
)
</script>
<style scoped>
.message-float-window {
position: fixed;
bottom: 0;
right: 0;
width: 400px;
max-height: 90vh;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
z-index: 2000;
display: flex;
flex-direction: column;
transition: height 0.25s ease;
/* 平滑过渡 */
}
.message-float-window iframe {
width: 100%;
flex: 1;
}
.float-actions {
position: absolute;
top: 4px;
right: 8px;
padding: 12px;
display: flex;
gap: 10px;
}
.float-actions i {
cursor: pointer;
font-size: 14px;
opacity: 0.9;
}
.float-actions i:hover {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div class="poll-section">
<div class="poll-options-row">
<span class="poll-row-title">投票选项</span>
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
<i
v-if="data.options.length > 2"
class="fa-solid fa-xmark remove-option-icon"
@click="removeOption(idx)"
></i>
</div>
<div class="add-option" @click="addOption">添加选项</div>
</div>
<div class="poll-time-row">
<span class="poll-row-title">投票结束时间</span>
<client-only>
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
<div class="poll-multiple-row">
<label class="poll-row-title">
<input type="checkbox" v-model="data.multiple" class="multiple-checkbox" />
多选
</label>
</div>
</div>
</template>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import FlatPickr from 'vue-flatpickr-component'
import BaseInput from '~/components/BaseInput.vue'
const props = defineProps({
data: {
type: Object,
required: true,
},
})
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const addOption = () => {
props.data.options.push('')
}
const removeOption = (idx) => {
if (props.data.options.length > 2) {
props.data.options.splice(idx, 1)
}
}
</script>
<style scoped>
.poll-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.poll-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.poll-option-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.remove-option-icon {
cursor: pointer;
}
.add-option {
color: var(--primary-color);
cursor: pointer;
width: fit-content;
margin-top: 5px;
}
.poll-options-row,
.poll-time-row {
display: flex;
flex-direction: column;
}
.poll-multiple-row {
display: flex;
align-items: center;
}
.multiple-checkbox {
margin-right: 5px;
}
.time-picker {
max-width: 200px;
height: 30px;
background-color: var(--lottery-background-color);
border-radius: 5px;
border: 1px solid var(--border-color);
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="post-prize-container" v-if="lottery">
<div class="prize-content">
<div class="prize-info">
<div class="prize-info-left">
<div class="prize-icon">
<BaseImage
class="prize-icon-img"
v-if="lottery.prizeIcon"
:src="lottery.prizeIcon"
alt="prize"
/>
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
</div>
<div class="prize-name">{{ lottery.prizeDescription }}</div>
<div class="prize-count">x {{ lottery.prizeCount }}</div>
</div>
<div class="prize-end-time prize-info-right">
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
<div class="prize-end-time-value">{{ countdown }}</div>
<div v-if="!isMobile" class="join-prize-button-container-desktop">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
</div>
<div v-if="isMobile" class="join-prize-button-container-mobile">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
<div class="prize-member-container">
<BaseImage
v-for="p in lotteryParticipants"
:key="p.id"
class="prize-member-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
<i class="fas fa-medal medal-icon"></i>
<span class="prize-member-winner-name">获奖者: </span>
<BaseImage
v-for="w in lotteryWinners"
:key="w.id"
class="prize-member-avatar"
:src="w.avatar"
alt="avatar"
@click="gotoUser(w.id)"
/>
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
{{ lotteryWinners[0].username }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
import { useIsMobile } from '~/utils/screen'
const props = defineProps({
lottery: { type: Object, required: true },
postId: { type: [String, Number], required: true },
})
const emit = defineEmits(['refresh'])
const isMobile = useIsMobile()
const loggedIn = computed(() => authState.loggedIn)
const lotteryParticipants = computed(() => props.lottery?.participants || [])
const lotteryWinners = computed(() => props.lottery?.winners || [])
const lotteryEnded = computed(() => {
if (!props.lottery || !props.lottery.endTime) return false
return new Date(props.lottery.endTime).getTime() <= Date.now()
})
const hasJoined = computed(() => {
if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const countdown = ref('00:00:00')
let timer = null
const updateCountdown = () => {
if (!props.lottery || !props.lottery.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(props.lottery.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (timer) {
clearInterval(timer)
timer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).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')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
updateCountdown()
if (timer) clearInterval(timer)
timer = setInterval(updateCountdown, 1000)
}
watch(
() => props.lottery?.endTime,
() => {
if (props.lottery && props.lottery.endTime) startCountdown()
},
)
onMounted(() => {
if (props.lottery && props.lottery.endTime) startCountdown()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const joinLottery = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/lottery/join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('已参与抽奖')
emit('refresh')
} else {
toast.error(data.error || '操作失败')
}
}
</script>
<style scoped>
.post-prize-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.prize-info {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
align-items: center;
}
.join-prize-button-container-mobile {
margin-top: 15px;
margin-bottom: 10px;
}
.prize-icon {
width: 24px;
height: 24px;
}
.default-prize-icon {
font-size: 24px;
opacity: 0.5;
}
.prize-icon-img {
width: 100%;
height: 100%;
}
.prize-name {
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-count {
font-size: 13px;
font-weight: bold;
opacity: 0.7;
margin-left: 10px;
color: var(--primary-color);
}
.prize-end-time {
display: flex;
flex-direction: row;
align-items: center;
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-end-time-title {
font-size: 13px;
opacity: 0.7;
margin-right: 5px;
}
.prize-end-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.prize-info-left,
.prize-info-right {
display: flex;
flex-direction: row;
align-items: center;
}
.join-prize-button {
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.join-prize-button:hover {
background-color: var(--primary-color-hover);
}
.join-prize-button-disabled {
text-align: center;
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
background-color: var(--primary-color-disabled);
opacity: 0.5;
cursor: not-allowed;
}
.prize-member-avatar {
width: 30px;
height: 30px;
margin-left: 3px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
}
.prize-member-winner {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin-top: 10px;
}
.medal-icon {
font-size: 16px;
color: var(--primary-color);
}
.prize-member-winner-name {
font-size: 13px;
opacity: 0.7;
}
@media (max-width: 768px) {
.join-prize-button,
.join-prize-button-disabled {
margin-left: 0;
}
}
</style>

View File

@@ -0,0 +1,411 @@
<template>
<div class="post-poll-container" v-if="poll">
<div class="poll-top-container">
<div class="poll-options-container">
<div v-if="showPollResult || pollEnded || hasVoted">
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
<div class="poll-option-info-container">
<div class="poll-option-text">{{ opt }}</div>
<div class="poll-option-progress-info">
{{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票)
</div>
</div>
<div class="poll-option-progress">
<div
class="poll-option-progress-bar"
:style="{ width: pollPercentages[idx] + '%' }"
></div>
</div>
<div class="poll-participants">
<BaseImage
v-for="p in pollOptionParticipants[idx] || []"
:key="p.id"
class="poll-participant-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
</div>
</div>
</div>
<div v-else>
<template v-if="poll.multiple">
<div
v-for="(opt, idx) in poll.options"
:key="idx"
class="poll-option"
@click="toggleOption(idx)"
>
<input
type="checkbox"
:checked="selectedOptions.includes(idx)"
class="poll-option-input"
/>
<span class="poll-option-text">{{ opt }}</span>
</div>
<div class="multi-selection-container">
<div class="multi-selection-title">
<i class="fas fa-info-circle info-icon"></i>
该投票为多选
</div>
<div class="join-poll-button" @click="submitMultiPoll">
<i class="fas fa-plus"></i> 加入投票
</div>
</div>
</template>
<template v-else>
<div
v-for="(opt, idx) in poll.options"
:key="idx"
class="poll-option"
@click="voteOption(idx)"
>
<input type="radio" :checked="false" name="poll-option" class="poll-option-input" />
<span class="poll-option-text">{{ opt }}</span>
</div>
</template>
</div>
</div>
<div class="poll-info">
<div class="total-votes">{{ pollParticipants.length }}</div>
<div class="total-votes-title">投票人</div>
</div>
</div>
<div class="poll-bottom-container">
<div
v-if="showPollResult && !pollEnded && !hasVoted"
class="poll-option-button"
@click="showPollResult = false"
>
<i class="fas fa-chevron-left"></i> 投票
</div>
<div
v-else-if="!pollEnded && !hasVoted"
class="poll-option-button"
@click="showPollResult = true"
>
<i class="fas fa-chart-bar"></i> 结果
</div>
<div class="poll-left-time">
<div class="poll-left-time-title">离结束还有</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
const props = defineProps({
poll: { type: Object, required: true },
postId: { type: [String, Number], required: true },
})
const emit = defineEmits(['refresh'])
const loggedIn = computed(() => authState.loggedIn)
const showPollResult = ref(false)
const pollParticipants = computed(() => props.poll?.participants || [])
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
const pollVotes = computed(() => props.poll?.votes || {})
const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0))
const pollPercentages = computed(() =>
props.poll
? props.poll.options.map((_, idx) => {
const c = pollVotes.value[idx] || 0
return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0
})
: [],
)
const pollEnded = computed(() => {
if (!props.poll || !props.poll.endTime) return false
return new Date(props.poll.endTime).getTime() <= Date.now()
})
const hasVoted = computed(() => {
if (!loggedIn.value) return false
return pollParticipants.value.some((p) => p.id === Number(authState.userId))
})
watch([hasVoted, pollEnded], ([voted, ended]) => {
if (voted || ended) showPollResult.value = true
})
const countdown = ref('00:00:00')
let timer = null
const updateCountdown = () => {
if (!props.poll || !props.poll.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(props.poll.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (timer) {
clearInterval(timer)
timer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).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')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
updateCountdown()
if (timer) clearInterval(timer)
timer = setInterval(updateCountdown, 1000)
}
watch(
() => props.poll?.endTime,
() => {
if (props.poll && props.poll.endTime) startCountdown()
},
)
onMounted(() => {
if (props.poll && props.poll.endTime) startCountdown()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const voteOption = async (idx) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/poll/vote?option=${idx}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('投票成功')
emit('refresh')
showPollResult.value = true
} else {
toast.error(data.error || '操作失败')
}
}
const selectedOptions = ref([])
const toggleOption = (idx) => {
const i = selectedOptions.value.indexOf(idx)
if (i >= 0) {
selectedOptions.value.splice(i, 1)
} else {
selectedOptions.value.push(idx)
}
}
const submitMultiPoll = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
if (!selectedOptions.value.length) {
toast.error('请选择至少一个选项')
return
}
const params = selectedOptions.value.map((o) => `option=${o}`).join('&')
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/poll/vote?${params}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('投票成功')
emit('refresh')
showPollResult.value = true
} else {
toast.error(data.error || '操作失败')
}
}
</script>
<style scoped>
.post-poll-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.poll-option-button {
color: var(--text-color);
padding: 5px 10px;
border-radius: 8px;
background-color: var(--poll-option-button-background-color);
cursor: pointer;
width: fit-content;
}
.poll-top-container {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--normal-border-color);
}
.poll-options-container {
display: flex;
flex-direction: column;
overflow-y: auto;
flex: 4;
border-right: 1px solid var(--normal-border-color);
}
.poll-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100px;
}
.total-votes {
font-size: 40px;
font-weight: bold;
opacity: 0.8;
}
.total-votes-title {
font-size: 18px;
opacity: 0.5;
}
.poll-option {
margin-bottom: 10px;
margin-right: 10px;
cursor: pointer;
display: flex;
align-items: center;
}
.poll-option-result {
margin-bottom: 10px;
margin-right: 10px;
gap: 5px;
display: flex;
flex-direction: column;
}
.poll-option-input {
margin-right: 10px;
width: 18px;
height: 18px;
accent-color: var(--primary-color);
border-radius: 50%;
border: 2px solid var(--primary-color);
}
.poll-option-text {
font-size: 18px;
}
.poll-bottom-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.poll-left-time {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 5px;
}
.poll-left-time-title {
font-size: 13px;
opacity: 0.7;
}
.poll-left-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.poll-option-progress {
position: relative;
background-color: rgb(187, 187, 187);
height: 20px;
border-radius: 5px;
overflow: hidden;
}
.poll-option-progress-bar {
background-color: var(--primary-color);
height: 100%;
}
.poll-option-info-container {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.poll-option-progress-info {
font-size: 12px;
line-height: 20px;
color: var(--text-color);
}
.multi-selection-container {
padding: 20px 15px 20px 5px;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.multi-selection-title {
font-size: 13px;
color: var(--text-color);
}
.info-icon {
margin-right: 5px;
}
.join-poll-button {
padding: 5px 10px;
background-color: var(--primary-color);
color: white;
border-radius: 8px;
cursor: pointer;
}
.join-poll-button:hover {
background-color: var(--primary-color-hover);
}
.poll-participants {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.poll-participant-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@@ -33,6 +33,7 @@ export default {
return [
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' },
{ id: 'POLL', name: '投票帖子', icon: 'fa-solid fa-square-poll-vertical' },
]
}

View File

@@ -6,7 +6,7 @@
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<template v-if="reactions.length < 4">
<template v-if="Object.keys(counts).length < 4">
<div
v-for="r in displayedReactions"
:key="r.type"
@@ -14,12 +14,12 @@
:class="{ selected: userReacted(r.type) }"
@click="toggleReaction(r.type)"
>
<img :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
<BaseImage :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
<div>{{ counts[r.type] }}</div>
</div>
<div class="reactions-viewer-item placeholder" @click="openPanel">
<i class="far fa-smile"></i>
<i class="far fa-smile reactions-viewer-item-placeholder-icon"></i>
<!-- <span class="reactions-viewer-item-placeholder-text">点击以表态</span> -->
</div>
</template>
@@ -30,14 +30,18 @@
class="reactions-viewer-item"
@click="openPanel"
>
<img :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
<BaseImage :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
</div>
<div class="reactions-count">{{ totalCount }}</div>
</template>
</div>
</div>
<div class="make-reaction-container">
<div class="make-reaction-item like-reaction" @click="toggleReaction('LIKE')">
<div
v-if="props.contentType !== 'message'"
class="make-reaction-item like-reaction"
@click="toggleReaction('LIKE')"
>
<i v-if="!userReacted('LIKE')" class="far fa-heart"></i>
<i v-else class="fas fa-heart"></i>
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
@@ -57,7 +61,7 @@
@click="toggleReaction(t)"
:class="{ selected: userReacted(t) }"
>
<img :src="reactionEmojiMap[t]" class="emoji" alt="emoji" /><span v-if="counts[t]">{{
<BaseImage :src="reactionEmojiMap[t]" class="emoji" alt="emoji" /><span v-if="counts[t]">{{
counts[t]
}}</span>
</div>
@@ -138,7 +142,9 @@ const toggleReaction = async (type) => {
const url =
props.contentType === 'post'
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
: props.contentType === 'comment'
? `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
: `${API_BASE_URL}/api/messages/${props.contentId}/reactions`
// optimistic update
const existingIdx = reactions.value.findIndex(
@@ -236,6 +242,10 @@ onMounted(async () => {
font-size: 16px;
}
.reactions-viewer-item-placeholder-icon {
opacity: 0.5;
}
.reactions-viewer-item-placeholder-text {
font-size: 14px;
padding-left: 5px;

View File

@@ -24,7 +24,7 @@
</template>
<template #option="{ option }">
<div class="search-option-item">
<img
<BaseImage
:src="option.avatar || '/default-avatar.svg'"
class="avatar"
@error="handleAvatarError"

View File

@@ -11,7 +11,7 @@
<div class="option-container">
<div class="option-main">
<template v-if="option.icon">
<img
<BaseImage
v-if="isImageIcon(option.icon)"
:src="option.icon"
class="option-icon"

View File

@@ -2,7 +2,7 @@
<div class="user-list">
<BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="fas fa-inbox" />
<div v-for="u in users" :key="u.id" class="user-item" @click="handleUserClick(u)">
<img :src="u.avatar" alt="avatar" class="user-avatar" />
<BaseImage :src="u.avatar" alt="avatar" class="user-avatar" />
<div class="user-info">
<div class="user-name">{{ u.username }}</div>
<div v-if="u.introduction" class="user-intro">{{ u.introduction }}</div>

View File

@@ -2,6 +2,7 @@ import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
ssr: true,
modules: ['@nuxt/image'],
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
@@ -87,24 +88,24 @@ export default defineNuxtConfig({
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'
}
}
},
},
},
// 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

@@ -9,20 +9,24 @@
"generate": "nuxt generate"
},
"dependencies": {
"@nuxt/image": "^1.11.0",
"@stomp/stompjs": "^7.0.0",
"cropperjs": "^1.6.2",
"echarts": "^5.6.0",
"flatpickr": "^4.6.13",
"highlight.js": "^11.11.1",
"ipx": "^3.1.1",
"ldrs": "^1.0.0",
"markdown-it": "^14.1.0",
"nuxt": "latest",
"mermaid": "^10.9.4",
"nprogress": "^0.2.0",
"nuxt": "latest",
"sanitize-html": "^2.17.0",
"sockjs-client": "^1.6.1",
"vditor": "^3.11.1",
"vue-easy-lightbox": "^1.19.0",
"vue-echarts": "^7.0.3",
"vue-toastification": "^2.0.0-rc.5",
"flatpickr": "^4.6.13",
"vue-flatpickr-component": "^12.0.0",
"@stomp/stompjs": "^7.0.0",
"sockjs-client": "^1.6.1"
"vue-toastification": "^2.0.0-rc.5"
}
}

View File

@@ -1,30 +1,23 @@
<template>
<div class="about-page">
<div class="about-tabs">
<div
v-for="tab in tabs"
:key="tab.name"
:class="['about-tabs-item', { selected: selectedTab === tab.name }]"
@click="selectTab(tab.name)"
>
<div class="about-tabs-item-label">{{ tab.label }}</div>
<BaseTabs v-model="selectedTab" :tabs="tabs">
<div class="about-loading" v-if="isFetching">
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
</div>
</div>
<div class="about-loading" v-if="isFetching">
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
</div>
<div
v-else
class="about-content"
v-html="renderMarkdown(content)"
@click="handleContentClick"
></div>
<div
v-else
class="about-content"
v-html="renderMarkdown(content)"
@click="handleContentClick"
></div>
</BaseTabs>
</div>
</template>
<script>
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import BaseTabs from '~/components/BaseTabs.vue'
export default {
name: 'AboutPageView',
@@ -32,27 +25,27 @@ export default {
const isFetching = ref(false)
const tabs = [
{
name: 'about',
key: 'about',
label: '关于',
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/about.md',
},
{
name: 'agreement',
key: 'agreement',
label: '用户协议',
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/agreement.md',
},
{
name: 'guideline',
key: 'guideline',
label: '创作准则',
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/guideline.md',
},
{
name: 'privacy',
key: 'privacy',
label: '隐私政策',
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
},
]
const selectedTab = ref(tabs[0].name)
const selectedTab = ref(tabs[0].key)
const content = ref('')
const loadContent = async (file) => {
@@ -71,21 +64,20 @@ export default {
}
}
const selectTab = (name) => {
selectedTab.value = name
const tab = tabs.find((t) => t.name === name)
if (tab) loadContent(tab.file)
}
onMounted(() => {
loadContent(tabs[0].file)
})
watch(selectedTab, (name) => {
const tab = tabs.find((t) => t.key === name)
if (tab) loadContent(tab.file)
})
const handleContentClick = (e) => {
handleMarkdownClick(e)
}
return { tabs, selectedTab, content, renderMarkdown, selectTab, isFetching, handleContentClick }
return { tabs, selectedTab, content, renderMarkdown, isFetching, handleContentClick }
},
}
</script>
@@ -97,28 +89,6 @@ export default {
margin: 0 auto;
}
.about-tabs {
top: calc(var(--header-height) + 1px);
background-color: var(--background-color-blur);
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--normal-border-color);
margin-bottom: 20px;
overflow-x: auto;
scrollbar-width: none;
}
.about-tabs-item {
padding: 10px 20px;
cursor: pointer;
white-space: nowrap;
}
.about-tabs-item.selected {
color: var(--primary-color);
border-bottom: 2px solid var(--primary-color);
}
.about-content {
line-height: 1.6;
padding: 20px;

View File

@@ -33,23 +33,11 @@
</template>
<script setup>
import { LineChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { onMounted, ref } from 'vue'
import VChart from 'vue-echarts'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
const dauOption = ref(null)
const newUserOption = ref(null)
const postOption = ref(null)

View File

@@ -7,7 +7,7 @@
<div class="activity-list-page-card" v-for="a in activities" :key="a.id">
<div class="activity-list-page-card-normal">
<div v-if="a.icon" class="activity-card-normal-left">
<img :src="a.icon" alt="avatar" class="activity-card-left-avatar-img" />
<BaseImage :src="a.icon" alt="avatar" class="activity-card-left-avatar-img" />
</div>
<div class="activity-card-normal-right">
<div class="activity-card-normal-right-header">

View File

@@ -70,11 +70,15 @@
<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.type === 'LOTTERY'" class="fa-solid fa-gift lottery-icon"></i>
<i
v-else-if="article.type === 'POLL'"
class="fa-solid fa-square-poll-vertical poll-icon"
></i>
{{ article.title }}
</NuxtLink>
<div class="article-item-description main-item">
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
{{ sanitizeDescription(article.description) }}
</div>
</NuxtLink>
<div class="article-info-container main-item">
<ArticleCategory :category="article.category" />
<ArticleTags :tags="article.tags" />
@@ -88,7 +92,7 @@
class="article-member-avatar-item"
:to="`/users/${member.id}`"
>
<img class="article-member-avatar-item-img" :src="member.avatar" alt="avatar" />
<BaseImage class="article-member-avatar-item-img" :src="member.avatar" alt="avatar" />
</NuxtLink>
</div>
@@ -125,6 +129,7 @@
<script setup>
import { computed, onMounted, onBeforeUnmount, nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
@@ -526,19 +531,23 @@ const sanitizeDescription = (text) => stripMarkdown(text)
.article-item-title {
margin-top: 10px;
font-size: 20px;
font-size: 18px;
text-decoration: none;
color: var(--text-color);
max-width: 100%;
font-weight: bold;
transition: color 0.2s ease;
}
.article-item-title:hover {
color: var(--primary-color);
text-decoration: underline;
transition: color 0.2s ease;
}
.pinned-icon,
.lottery-icon {
.lottery-icon,
.poll-icon {
margin-right: 4px;
color: var(--primary-color);
}
@@ -546,13 +555,23 @@ const sanitizeDescription = (text) => stripMarkdown(text)
.article-item-description {
max-width: 100%;
margin-top: 10px;
font-size: 14px;
color: gray;
font-size: 13px;
color: rgba(140, 140, 140, 0.888);
display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
letter-spacing: 0.01em;
font-weight: 400;
text-decoration: none;
transition: color 0.2s ease;
}
.article-item-description:hover {
color: var(--primary-color);
cursor: pointer;
transition: color 0.2s ease;
}
.article-info-container {

View File

@@ -1,15 +1,16 @@
<template>
<div class="chat-container">
<div class="chat-container" :class="{ float: isFloatMode }">
<div v-if="!loading" class="chat-header">
<NuxtLink to="/message-box" class="back-button">
<i class="fas fa-arrow-left"></i>
</NuxtLink>
<h2 class="participant-name">
{{ isChannel ? conversationName : otherParticipant?.username }}
</h2>
<div class="chat-controls">
<i v-if="!isFloat" class="fas fa-window-minimize control-icon" @click="minimizeChat"></i>
<i v-else class="fas fa-expand control-icon" @click="maximizeChat"></i>
<div class="header-main">
<div class="back-button" @click="goBack">
<i class="fas fa-arrow-left"></i>
</div>
<h2 class="participant-name">
{{ isChannel ? conversationName : otherParticipant?.username }}
</h2>
</div>
<div v-if="!isFloatMode" class="float-control">
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
</div>
</div>
@@ -24,7 +25,7 @@
{{ loadingMore ? '加载中...' : '查看更多消息' }}
</div>
</div>
<BaseTimeline :items="messages" hover>
<BaseTimeline :items="messages">
<template #item="{ item }">
<div class="message-header">
<div class="user-name">
@@ -34,9 +35,21 @@
{{ TimeManager.format(item.createdAt) }}
</div>
</div>
<div v-if="item.replyTo" class="reply-preview info-content-text">
<div class="reply-author">{{ item.replyTo.sender.username }}</div>
<div class="reply-content" v-html="renderMarkdown(item.replyTo.content)"></div>
</div>
<div class="message-content">
<div class="info-content-text" v-html="renderMarkdown(item.content)"></div>
</div>
<ReactionsGroup
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
>
<i class="fas fa-reply reply-btn" @click="setReply(item)"> 写个回复...</i>
</ReactionsGroup>
</template>
</BaseTimeline>
<div class="empty-container">
@@ -50,6 +63,11 @@
</div>
<div class="message-input-area">
<div v-if="replyTo" class="active-reply">
正在回复 {{ replyTo.sender.username }}:
{{ stripMarkdownLength(replyTo.content, 50) }}
<i class="fas fa-times close-reply" @click="replyTo = null"></i>
</div>
<MessageEditor :loading="sending" @submit="sendMessage" />
</div>
</div>
@@ -69,8 +87,9 @@ import {
import { useRoute } from 'vue-router'
import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main'
import { renderMarkdown } from '~/utils/markdown'
import { renderMarkdown, stripMarkdownLength } from '~/utils/markdown'
import MessageEditor from '~/components/MessageEditor.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import { useWebSocket } from '~/composables/useWebSocket'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
@@ -86,10 +105,6 @@ const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread } = useChannelsUnreadCount()
let subscription = null
const chatFloating = useState('chatFloating', () => false)
const chatPath = useState('chatPath', () => '/message-box')
const isFloat = computed(() => route.query.float === '1')
const messages = ref([])
const participants = ref([])
const loading = ref(true)
@@ -98,13 +113,22 @@ const error = ref(null)
const conversationId = route.params.id
const currentUser = ref(null)
const messagesListEl = ref(null)
let lastMessageEl = null
const currentPage = ref(0)
const totalPages = ref(0)
const loadingMore = ref(false)
let scrollInterval = null
const conversationName = ref('')
const isChannel = ref(false)
const isFloatMode = computed(() => route.query.float !== undefined)
const floatRoute = useState('messageFloatRoute')
const replyTo = ref(null)
const isUserNearBottom = ref(true)
function updateNearBottom() {
const el = messagesListEl.value
if (!el) return
const threshold = 40 // px
isUserNearBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
}
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
@@ -115,34 +139,37 @@ const otherParticipant = computed(() => {
return participants.value.find((p) => p.id !== currentUser.value.id)
})
function isSentByCurrentUser(message) {
return message.sender.id === currentUser.value?.id
function setReply(message) {
replyTo.value = message
}
function handleAvatarError(event) {
event.target.src = '/default-avatar.svg'
}
function minimizeChat() {
chatPath.value = route.fullPath
chatFloating.value = true
navigateTo('/')
}
function maximizeChat() {
if (window.parent) {
window.parent.postMessage(
{
type: 'maximize-chat',
path: route.fullPath.replace('?float=1', '').replace('&float=1', ''),
},
'*',
)
/** 改造:滚动函数 —— smooth & instant */
function scrollToBottomSmooth() {
const el = messagesListEl.value
if (!el) return
// 优先使用原生 smooth失败则降级
try {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
} catch {
// 降级:简易动画
const start = el.scrollTop
const end = el.scrollHeight
const duration = 200
const startTs = performance.now()
function step(now) {
const p = Math.min(1, (now - startTs) / duration)
el.scrollTop = start + (end - start) * p
if (p < 1) requestAnimationFrame(step)
}
requestAnimationFrame(step)
}
}
// No changes needed here, as renderMarkdown is now imported.
// The old function is removed.
function scrollToBottomInstant() {
const el = messagesListEl.value
if (!el) return
el.scrollTop = el.scrollHeight
}
async function fetchMessages(page = 0) {
if (page === 0) {
@@ -177,12 +204,11 @@ async function fetchMessages(page = 0) {
isChannel.value = conversationData.channel
}
// Since the backend sorts by descending, we need to reverse for correct chat order
const newMessages = pageData.content.reverse().map((item) => ({
...item,
src: item.sender.avatar,
iconClick: () => {
navigateTo(`/users/${item.sender.id}`, { replace: true })
openUser(item.sender.id)
},
}))
@@ -198,12 +224,16 @@ async function fetchMessages(page = 0) {
currentPage.value = pageData.number
totalPages.value = pageData.totalPages
// Scrolling is now fully handled by the watcher
await nextTick()
if (page > 0 && list) {
// 加载更多:保持原视口位置
const newScrollHeight = list.scrollHeight
list.scrollTop = newScrollHeight - oldScrollHeight
} else if (page === 0) {
// 首次加载:定位到底部(不用动画,避免“闪动感”)
scrollToBottomInstant()
}
updateNearBottom()
} catch (e) {
error.value = e.message
toast.error(e.message)
@@ -234,7 +264,7 @@ async function sendMessage(content, clearInput) {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content }),
body: JSON.stringify({ content, replyToId: replyTo.value?.id }),
},
)
} else {
@@ -252,6 +282,7 @@ async function sendMessage(content, clearInput) {
body: JSON.stringify({
recipientId: recipient.id,
content: content,
replyToId: replyTo.value?.id,
}),
})
}
@@ -262,13 +293,15 @@ async function sendMessage(content, clearInput) {
...newMessage,
src: newMessage.sender.avatar,
iconClick: () => {
navigateTo(`/users/${newMessage.sender.id}`, { replace: true })
openUser(newMessage.sender.id)
},
})
clearInput()
setTimeout(() => {
scrollToBottom()
}, 100)
replyTo.value = null
await nextTick()
// 仅“发送消息成功后”才平滑滚动到底部
scrollToBottomSmooth()
} catch (e) {
toast.error(e.message)
} finally {
@@ -284,7 +317,6 @@ async function markConversationAsRead() {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
// After marking as read, refresh the global unread count
refreshGlobalUnreadCount()
refreshChannelUnread()
} catch (e) {
@@ -292,37 +324,12 @@ async function markConversationAsRead() {
}
}
function scrollToBottom() {
if (messagesListEl.value) {
const element = messagesListEl.value
// 強制滾動到底部,使用 smooth 行為確保視覺效果
element.scrollTop = element.scrollHeight
// 再次確認滾動位置
setTimeout(() => {
if (element.scrollTop < element.scrollHeight - element.clientHeight) {
element.scrollTop = element.scrollHeight
}
}, 50)
}
}
watch(
messages,
async (newMessages) => {
if (newMessages.length === 0) return
await nextTick()
// Simple, reliable scroll to bottom
setTimeout(() => {
scrollToBottom()
}, 100)
},
{ deep: true },
)
onMounted(async () => {
// 监听列表滚动,实时感知是否接近底部
if (messagesListEl.value) {
messagesListEl.value.addEventListener('scroll', updateNearBottom, { passive: true })
}
currentUser.value = await fetchCurrentUser()
if (currentUser.value) {
await fetchMessages(0)
@@ -339,23 +346,21 @@ onMounted(async () => {
watch(isConnected, (newValue) => {
if (newValue) {
// 等待一小段时间确保连接稳定
setTimeout(() => {
subscription = subscribe(`/topic/conversation/${conversationId}`, (message) => {
subscription = subscribe(`/topic/conversation/${conversationId}`, async (message) => {
// 避免重复显示当前用户发送的消息
if (message.sender.id !== currentUser.value.id) {
messages.value.push({
...message,
src: message.sender.avatar,
iconClick: () => {
navigateTo(`/users/${message.sender.id}`, { replace: true })
openUser(message.sender.id)
},
})
// 实时收到消息时自动标记已读
// 收到消息后只标记已读,不强制滚动(符合“非发送不拉底”)
markConversationAsRead()
setTimeout(() => {
scrollToBottom()
}, 100)
await nextTick()
updateNearBottom()
}
})
}, 500)
@@ -363,23 +368,12 @@ watch(isConnected, (newValue) => {
})
onActivated(async () => {
// This will be called every time the component is activated (navigated to)
// 返回页面时:刷新数据与已读,不做强制滚动,保持用户当前位置
if (currentUser.value) {
await fetchMessages(0)
await markConversationAsRead()
// 確保滾動到底部 - 使用多重延遲策略
await nextTick()
setTimeout(() => {
scrollToBottom()
}, 100)
setTimeout(() => {
scrollToBottom()
}, 300)
setTimeout(() => {
scrollToBottom()
}, 500)
updateNearBottom()
if (!isConnected.value) {
const token = getToken()
if (token) connect(token)
@@ -400,8 +394,33 @@ onUnmounted(() => {
subscription.unsubscribe()
subscription = null
}
if (messagesListEl.value) {
messagesListEl.value.removeEventListener('scroll', updateNearBottom)
}
disconnect()
})
function minimize() {
floatRoute.value = route.fullPath
navigateTo('/')
}
function openUser(id) {
if (isFloatMode.value) {
// 先不处理...
// navigateTo(`/users/${id}?float=1`)
} else {
navigateTo(`/users/${id}`, { replace: true })
}
}
function goBack() {
if (isFloatMode.value) {
navigateTo('/message-box?float=1')
} else {
navigateTo('/message-box')
}
}
</script>
<style scoped>
@@ -414,8 +433,13 @@ onUnmounted(() => {
position: relative;
}
.chat-container.float {
height: 100vh;
}
.chat-header {
display: flex;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
@@ -426,6 +450,24 @@ onUnmounted(() => {
backdrop-filter: var(--blur-10);
}
.header-main {
display: flex;
align-items: center;
}
.float-control {
position: absolute;
top: 0;
right: 0;
text-align: right;
padding: 12px 12px;
cursor: pointer;
}
.float-control i {
cursor: pointer;
}
.back-button {
font-size: 18px;
color: var(--text-color-primary);
@@ -437,11 +479,11 @@ onUnmounted(() => {
font-size: 18px;
font-weight: 600;
margin: 0;
flex: 1;
}
.messages-list {
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
padding-bottom: 100px;
display: flex;
@@ -541,25 +583,59 @@ onUnmounted(() => {
color: var(--text-color-secondary);
}
@media (max-width: 768px) {
.messages-list {
padding: 10px;
}
}
.message-input-area {
margin-left: 10px;
margin-right: 10px;
}
.chat-controls {
margin-left: auto;
cursor: pointer;
display: flex;
align-items: center;
.reply-preview {
margin-top: 10px;
padding: 10px;
border-left: 5px solid var(--primary-color);
margin-bottom: 5px;
font-size: 13px;
background-color: var(--menu-selected-background-color);
}
.control-icon {
font-size: 16px;
.reply-author {
font-weight: bold;
margin-bottom: 2px;
}
.reply-btn {
cursor: pointer;
padding: 4px;
opacity: 0.6;
font-size: 12px;
}
.reply-btn:hover {
opacity: 1;
}
.active-reply {
background-color: var(--bg-color-soft);
padding: 5px 10px;
border-left: 5px solid var(--primary-color);
margin-bottom: 5px;
font-size: 13px;
}
.close-reply {
margin-left: 8px;
cursor: pointer;
}
@media (max-height: 200px) {
.messages-list,
.message-input-area {
display: none;
}
}
@media (max-width: 768px) {
.messages-list {
padding: 10px;
}
}
</style>

View File

@@ -1,125 +1,125 @@
<template>
<div class="messages-container">
<div class="chat-controls">
<i v-if="!isFloat" class="fas fa-window-minimize control-icon" @click="minimizeChat"></i>
<i v-else class="fas fa-expand control-icon" @click="maximizeChat"></i>
<div class="page-title">
<i class="fas fa-comments"></i>
<span class="page-title-text">选择聊天</span>
</div>
<div class="tabs">
<div :class="['tab', { active: activeTab === 'messages' }]" @click="activeTab = 'messages'">
站内信
</div>
<div :class="['tab', { active: activeTab === 'channels' }]" @click="switchToChannels">
频道
</div>
<div v-if="!isFloatMode" class="float-control">
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
</div>
<div v-if="activeTab === 'messages'">
<div v-if="loading" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else-if="error" class="error-container">
<div class="error-text">{{ error }}</div>
</div>
<div v-if="!loading" class="search-container">
<SearchPersonDropdown />
</div>
<div v-if="!loading && conversations.length === 0" class="empty-container">
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="fas fa-inbox" />
</div>
<div
v-if="!loading"
v-for="convo in conversations"
:key="convo.id"
class="conversation-item"
@click="goToConversation(convo.id)"
>
<div class="conversation-avatar">
<img
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
:alt="getOtherParticipant(convo)?.username || '用户'"
class="avatar-img"
@error="handleAvatarError"
/>
<BaseTabs v-model="activeTab" :tabs="tabs">
<div v-if="activeTab === 'messages'">
<div v-if="loading" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div class="conversation-content">
<div class="conversation-header">
<div class="participant-name">
{{ getOtherParticipant(convo)?.username || '未知用户' }}
</div>
<div class="message-time">
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
</div>
</div>
<div class="last-message-row">
<div class="last-message">
{{
convo.lastMessage ? stripMarkdownLength(convo.lastMessage.content, 100) : '暂无消息'
}}
</div>
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
{{ convo.unreadCount }}
</div>
</div>
<div v-else-if="error" class="error-container">
<div class="error-text">{{ error }}</div>
</div>
</div>
</div>
<div v-else>
<div v-if="loadingChannels" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else>
<div v-if="channels.length === 0" class="empty-container">
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
<div v-if="!loading && !isFloatMode" class="search-container">
<SearchPersonDropdown />
</div>
<div v-if="!loading && conversations.length === 0" class="empty-container">
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="fas fa-inbox" />
</div>
<div
v-for="ch in channels"
:key="ch.id"
v-if="!loading"
v-for="convo in conversations"
:key="convo.id"
class="conversation-item"
@click="goToChannel(ch.id)"
@click="goToConversation(convo.id)"
>
<div class="conversation-avatar">
<img
:src="ch.avatar || '/default-avatar.svg'"
:alt="ch.name"
<BaseImage
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
:alt="getOtherParticipant(convo)?.username || '用户'"
class="avatar-img"
@error="handleAvatarError"
/>
</div>
<div class="conversation-content">
<div class="conversation-header">
<div class="participant-name">
{{ ch.name }}
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
{{ getOtherParticipant(convo)?.username || '未知用户' }}
</div>
<div class="message-time">
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
</div>
</div>
<div class="last-message-row">
<div class="last-message">
{{
ch.lastMessage ? stripMarkdownLength(ch.lastMessage.content, 100) : ch.description
convo.lastMessage
? stripMarkdownLength(convo.lastMessage.content, 100)
: '暂无消息'
}}
</div>
<div class="member-count">成员 {{ ch.memberCount }}</div>
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
{{ convo.unreadCount }}
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else>
<div v-if="loadingChannels" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else>
<div v-if="channels.length === 0" class="empty-container">
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
</div>
<div
v-for="ch in channels"
:key="ch.id"
class="conversation-item"
@click="goToChannel(ch.id)"
>
<div class="conversation-avatar">
<BaseImage
:src="ch.avatar || '/default-avatar.svg'"
:alt="ch.name"
class="avatar-img"
@error="handleAvatarError"
/>
</div>
<div class="conversation-content">
<div class="conversation-header">
<div class="participant-name">
{{ ch.name }}
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
</div>
<div class="message-time">
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
</div>
</div>
<div class="last-message-row">
<div class="last-message">
{{
ch.lastMessage
? stripMarkdownLength(ch.lastMessage.content, 100)
: ch.description
}}
</div>
<div class="member-count">成员 {{ ch.memberCount }}</div>
</div>
</div>
</div>
</div>
</div>
</BaseTabs>
</div>
</template>
<script setup>
import { ref, onUnmounted, watch, onActivated, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRoute } from 'vue-router'
import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main'
import { useWebSocket } from '~/composables/useWebSocket'
@@ -129,12 +129,13 @@ import TimeManager from '~/utils/time'
import { stripMarkdownLength } from '~/utils/markdown'
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTabs from '~/components/BaseTabs.vue'
const config = useRuntimeConfig()
const conversations = ref([])
const loading = ref(true)
const error = ref(null)
const router = useRouter()
const route = useRoute()
const currentUser = ref(null)
const API_BASE_URL = config.public.apiBaseUrl
@@ -144,13 +145,15 @@ const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadF
useChannelsUnreadCount()
let subscription = null
const chatFloating = useState('chatFloating', () => false)
const chatPath = useState('chatPath', () => '/message-box')
const isFloat = computed(() => route.query.float === '1')
const activeTab = ref('messages')
const activeTab = ref('channels')
const tabs = [
{ key: 'messages', label: '站内信' },
{ key: 'channels', label: '频道' },
]
const channels = ref([])
const loadingChannels = ref(false)
const isFloatMode = computed(() => route.query.float === '1')
const floatRoute = useState('messageFloatRoute')
async function fetchConversations() {
const token = getToken()
@@ -158,6 +161,7 @@ async function fetchConversations() {
toast.error('请先登录')
return
}
loading.value = true
try {
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
method: 'GET',
@@ -214,12 +218,13 @@ async function fetchChannels() {
}
}
function switchToChannels() {
activeTab.value = 'channels'
if (channels.value.length === 0) {
watch(activeTab, (tab) => {
if (tab === 'messages') {
fetchConversations()
} else {
fetchChannels()
}
}
})
async function goToChannel(id) {
const token = getToken()
@@ -232,37 +237,26 @@ async function goToChannel(id) {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
router.push(`/message-box/${id}`)
if (isFloatMode.value) {
navigateTo(`/message-box/${id}?float=1`)
} else {
navigateTo(`/message-box/${id}`)
}
} catch (e) {
toast.error(e.message)
}
}
function minimizeChat() {
chatPath.value = route.fullPath
chatFloating.value = true
navigateTo('/')
}
function maximizeChat() {
if (window.parent) {
window.parent.postMessage(
{
type: 'maximize-chat',
path: route.fullPath.replace('?float=1', '').replace('&float=1', ''),
},
'*',
)
}
}
onActivated(async () => {
loading.value = true
currentUser.value = await fetchCurrentUser()
if (currentUser.value) {
await fetchConversations()
refreshGlobalUnreadCount() // Refresh global count when entering the list
if (activeTab.value === 'messages') {
await fetchConversations()
} else {
await fetchChannels()
}
refreshGlobalUnreadCount()
refreshChannelUnread()
const token = getToken()
if (token && !isConnected.value) {
@@ -299,7 +293,16 @@ onUnmounted(() => {
})
function goToConversation(id) {
router.push(`/message-box/${id}`)
if (isFloatMode.value) {
navigateTo(`/message-box/${id}?float=1`)
} else {
navigateTo(`/message-box/${id}`)
}
}
function minimize() {
floatRoute.value = route.fullPath
navigateTo('/')
}
</script>
@@ -308,18 +311,30 @@ function goToConversation(id) {
position: relative;
}
.tabs {
.float-control {
position: absolute;
top: 0;
right: 0;
text-align: right;
padding: 12px 12px;
}
.float-control i {
cursor: pointer;
}
:deep(.base-tabs-header) {
display: flex;
border-bottom: 1px solid var(--normal-border-color);
margin-bottom: 16px;
}
.tab {
:deep(.base-tabs-item) {
padding: 10px 20px;
cursor: pointer;
}
.tab.active {
:deep(.base-tabs-item.selected) {
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
}
@@ -341,6 +356,21 @@ function goToConversation(id) {
margin-bottom: 24px;
}
.page-title {
padding: 12px;
display: none;
flex-direction: row;
gap: 10px;
}
.page-title-text {
margin-left: 10px;
}
.page-title-text:hover {
text-decoration: underline;
}
.messages-title {
font-size: 28px;
font-weight: 600;
@@ -465,19 +495,21 @@ function goToConversation(id) {
margin-left: 4px;
}
.chat-controls {
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
z-index: 10;
@media (max-height: 200px) {
.page-title {
display: block;
}
:deep(.base-tabs-header),
.loading-message,
.error-container,
.search-container,
.empty-container,
.conversation-item {
display: none;
}
}
.control-icon {
font-size: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.conversation-item {
margin-left: 10px;

View File

File diff suppressed because it is too large Load Diff

View File

@@ -35,59 +35,21 @@
</div>
</div>
</div>
<div v-if="postType === 'LOTTERY'" class="lottery-section">
<AvatarCropper
:src="tempPrizeIcon"
:show="showPrizeCropper"
@close="showPrizeCropper = false"
@crop="onPrizeCropped"
/>
<div class="prize-row">
<span class="prize-row-title">奖品图片</span>
<label class="prize-container">
<img v-if="prizeIcon" :src="prizeIcon" class="prize-preview" alt="prize" />
<i v-else class="fa-solid fa-image default-prize-icon"></i>
<div class="prize-overlay">上传奖品图片</div>
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
</label>
</div>
<div class="prize-name-row">
<span class="prize-row-title">奖品描述</span>
<BaseInput v-model="prizeDescription" placeholder="奖品描述" />
</div>
<div class="prize-count-row">
<span class="prize-row-title">奖品数量</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="prizeCount"
min="1"
/>
</div>
</div>
<div class="prize-time-row">
<span class="prize-row-title">抽奖结束时间</span>
<client-only>
<flat-pickr v-model="endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
</div>
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
<PollForm v-if="postType === 'POLL'" :data="poll" />
</div>
</div>
</template>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import { computed, onMounted, ref, watch } from 'vue'
import FlatPickr from 'vue-flatpickr-component'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import { computed, onMounted, ref, reactive } from 'vue'
import CategorySelect from '~/components/CategorySelect.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import PostEditor from '~/components/PostEditor.vue'
import PostTypeSelect from '~/components/PostTypeSelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import LotteryForm from '~/components/LotteryForm.vue'
import PollForm from '~/components/PollForm.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
@@ -98,41 +60,27 @@ const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const prizeIcon = ref('')
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 lottery = reactive({
prizeIcon: '',
prizeIconFile: null,
tempPrizeIcon: '',
showPrizeCropper: false,
prizeName: '',
prizeDescription: '',
prizeCount: 1,
pointCost: 0,
endTime: null,
})
const poll = reactive({
options: ['', ''],
endTime: null,
multiple: false,
})
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 file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
tempPrizeIcon.value = reader.result
showPrizeCropper.value = true
}
reader.readAsDataURL(file)
}
}
const onPrizeCropped = ({ file, url }) => {
prizeIconFile.value = file
prizeIcon.value = url
}
watch(prizeCount, (val) => {
if (!val || val < 1) prizeCount.value = 1
})
const loadDraft = async () => {
const token = getToken()
if (!token) return
@@ -162,14 +110,19 @@ const clearPost = async () => {
selectedCategory.value = ''
selectedTags.value = []
postType.value = 'NORMAL'
prizeIcon.value = ''
prizeIconFile.value = null
tempPrizeIcon.value = ''
showPrizeCropper.value = false
prizeDescription.value = ''
prizeCount.value = 1
endTime.value = null
lottery.prizeIcon = ''
lottery.prizeIconFile = null
lottery.tempPrizeIcon = ''
lottery.showPrizeCropper = false
lottery.prizeName = ''
lottery.prizeDescription = ''
lottery.prizeCount = 1
lottery.pointCost = 0
lottery.endTime = null
startTime.value = null
poll.options = ['', '']
poll.endTime = null
poll.multiple = false
// 删除草稿
const token = getToken()
@@ -299,31 +252,45 @@ const submitPost = async () => {
return
}
if (postType.value === 'LOTTERY') {
if (!prizeIcon.value) {
if (!lottery.prizeIcon) {
toast.error('请上传奖品图片')
return
}
if (!prizeCount.value || prizeCount.value < 1) {
if (!lottery.prizeCount || lottery.prizeCount < 1) {
toast.error('奖品数量必须大于0')
return
}
if (!prizeDescription.value) {
if (!lottery.prizeDescription) {
toast.error('请输入奖品描述')
return
}
if (!endTime.value) {
if (!lottery.endTime) {
toast.error('请选择抽奖结束时间')
return
}
if (lottery.pointCost < 0 || lottery.pointCost > 100) {
toast.error('参与积分需在0到100之间')
return
}
}
if (postType.value === 'POLL') {
if (poll.options.length < 2 || poll.options.some((o) => !o.trim())) {
toast.error('请填写至少两个投票选项')
return
}
if (!poll.endTime) {
toast.error('请选择投票结束时间')
return
}
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
let prizeIconUrl = prizeIcon.value
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
let prizeIconUrl = lottery.prizeIcon
if (postType.value === 'LOTTERY' && lottery.prizeIconFile) {
const form = new FormData()
form.append('file', prizeIconFile.value)
form.append('file', lottery.prizeIconFile)
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
@@ -349,16 +316,21 @@ const submitPost = async () => {
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
options: postType.value === 'POLL' ? poll.options : undefined,
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
startTime:
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
endTime:
postType.value === 'LOTTERY'
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: postType.value === 'POLL'
? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
}),
})
const data = await res.json()
@@ -493,121 +465,6 @@ const submitPost = async () => {
padding-bottom: 50px;
}
.lottery-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.prize-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.prize-row {
display: flex;
flex-direction: column;
}
.prize-name-row {
display: flex;
flex-direction: column;
}
.prize-container {
position: relative;
width: 100px;
height: 100px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
background-color: var(--lottery-background-color);
display: flex;
align-items: center;
justify-content: center;
}
.default-prize-icon {
font-size: 30px;
opacity: 0.1;
color: var(--text-color);
}
.prize-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.prize-input {
display: none;
}
.prize-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.prize-container:hover .prize-overlay {
opacity: 1;
}
.prize-count-row,
.prize-time-row {
display: flex;
flex-direction: column;
}
.prize-count-input {
display: flex;
align-items: center;
}
.prize-name-input {
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
margin-left: 10px;
font-size: 16px;
color: var(--text-color);
}
.prize-count-input-field {
width: 50px;
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
font-size: 16px;
color: var(--text-color);
background-color: var(--lottery-background-color);
}
.time-picker {
max-width: 200px;
height: 30px;
background-color: var(--lottery-background-color);
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
font-size: 16px;
color: var(--text-color);
}
@media (max-width: 768px) {
.new-post-page {
width: calc(100vw - 20px);

View File

@@ -1,159 +1,202 @@
<template>
<div class="point-mall-page">
<div class="point-tabs">
<div
:class="['point-tab-item', { selected: selectedTab === 'mall' }]"
@click="selectedTab = 'mall'"
>
积分兑换
</div>
<div
:class="['point-tab-item', { selected: selectedTab === 'history' }]"
@click="selectedTab = 'history'"
>
积分历史
</div>
</div>
<BaseTabs v-model="selectedTab" :tabs="tabs">
<template v-if="selectedTab === 'mall'">
<div class="point-mall-page-content">
<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>
<template v-if="selectedTab === 'mall'">
<div class="point-mall-page-content">
<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>
<section class="trend" v-if="trendOption">
<div class="section-title">积分走势</div>
<ClientOnly>
<VChart :option="trendOption" :autoresize="true" style="height: 300px" />
</ClientOnly>
</section>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
</section>
<div class="loading-points-container" v-if="isLoading">
<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">
<BaseImage class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
</div>
</div>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
/>
</div>
</template>
<template v-else>
<div class="loading-points-container" v-if="historyLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span
class="point-value"
>{{ point }}</span
>
</p>
</div>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
</div>
</div>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
<BasePlaceholder
v-else-if="histories.length === 0"
text="暂无积分记录"
icon="fas fa-inbox"
/>
</div>
</template>
<template v-else>
<div class="loading-points-container" v-if="historyLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<BasePlaceholder v-else-if="histories.length === 0" text="暂无积分记录" icon="fas fa-inbox" />
<div class="timeline-container" v-else>
<BaseTimeline :items="histories">
<template #item="{ item }">
<div class="history-content">
<template v-if="item.type === 'POST'">
发送帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT'">
在文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
<template v-if="!item.fromUserId">
发送评论
<div class="timeline-container" v-else>
<BaseTimeline :items="histories">
<template #item="{ item }">
<div class="history-content">
<template v-if="item.type === 'POST'">
发送帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT'">
在文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
<template v-if="!item.fromUserId">
发送评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
</template>
<template v-else>
被评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
</template>
</template>
<template v-else-if="item.type === 'POST_LIKE_CANCELLED' && item.fromUserId">
你的帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">
{{ item.postTitle }}
</NuxtLink>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">
{{ item.fromUserName }}
</NuxtLink>
取消点赞扣除{{ -item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT_LIKE_CANCELLED' && item.fromUserId">
你的评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.commentContent, 100) }}
</NuxtLink>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">
{{ item.fromUserName }}
</NuxtLink>
取消点赞扣除{{ -item.amount }}积分
</template>
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
按赞获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT_LIKED' && item.fromUserId">
评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
按赞获得{{ item.amount }}积分
</template>
<template v-else>
被评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
<template v-else-if="item.type === 'INVITE' && item.fromUserId">
邀请了好友
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
加入社区 🎉获得 {{ item.amount }} 积分
</template>
</template>
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
按赞获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT_LIKED' && item.fromUserId">
评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
按赞获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'INVITE' && item.fromUserId">
邀请了好友
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
加入社区 🎉获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'FEATURE'">
文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
被收录为精选获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'REDEEM'">
兑换商品消耗 {{ -item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
</div>
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
</template>
</BaseTimeline>
</div>
</template>
<template v-else-if="item.type === 'FEATURE'">
文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
被收录为精选获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'REDEEM'">
兑换商品消耗 {{ -item.amount }} 积分
</template>
<template v-else-if="item.type === 'LOTTERY_JOIN'">
参与抽奖帖
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
消耗 {{ -item.amount }} 积分
</template>
<template v-else-if="item.type === 'LOTTERY_REWARD'">
你的抽奖帖
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
参与获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
</div>
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
</template>
</BaseTimeline>
</div>
</template>
</BaseTabs>
</div>
</template>
@@ -166,16 +209,22 @@ import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
import BaseTabs from '~/components/BaseTabs.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const selectedTab = ref('mall')
const tabs = [
{ key: 'mall', label: '积分兑换' },
{ key: 'history', label: '积分历史' },
]
const point = ref(null)
const isLoading = ref(false)
const histories = ref([])
const historyLoading = ref(false)
const historyLoaded = ref(false)
const trendOption = ref(null)
const pointRules = [
'发帖:每天前两次,每次 30 积分',
@@ -201,6 +250,30 @@ const iconMap = {
SYSTEM_ONLINE: 'fas fa-clock',
REDEEM: 'fas fa-gift',
FEATURE: 'fas fa-star',
LOTTERY_JOIN: 'fas fa-ticket-alt',
LOTTERY_REWARD: 'fas fa-ticket-alt',
POST_LIKE_CANCELLED: 'fas fa-thumbs-down',
COMMENT_LIKE_CANCELLED: 'fas fa-thumbs-down',
}
const loadTrend = async () => {
if (!authState.loggedIn) return
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/point-histories/trend?days=30`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
const dates = data.map((d) => d.date)
const values = data.map((d) => d.value)
trendOption.value = {
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: dates, boundaryGap: false },
yAxis: { type: 'value' },
series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }],
dataZoom: [{ type: 'slider', start: 80 }, { type: 'inside' }],
}
}
}
onMounted(async () => {
@@ -208,8 +281,10 @@ onMounted(async () => {
if (authState.loggedIn) {
const user = await fetchCurrentUser()
point.value = user ? user.point : null
await Promise.all([loadGoods(), loadTrend()])
} else {
await loadGoods()
}
await loadGoods()
isLoading.value = false
})
@@ -295,17 +370,17 @@ const submitRedeem = async () => {
padding: 0 20px;
}
.point-tabs {
:deep(.base-tabs-header) {
display: flex;
border-bottom: 1px solid var(--normal-border-color);
}
.point-tab-item {
:deep(.base-tabs-item) {
padding: 10px 15px;
cursor: pointer;
}
.point-tab-item.selected {
:deep(.base-tabs-item.selected) {
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
}
@@ -345,7 +420,8 @@ const submitRedeem = async () => {
}
.rules,
.goods {
.goods,
.trend {
margin-top: 20px;
}

View File

@@ -47,7 +47,7 @@
<div class="info-content-container author-info-container">
<div class="user-avatar-container" @click="gotoProfile">
<div class="user-avatar-item">
<img class="user-avatar-item-img" :src="author.avatar" alt="avatar" />
<BaseImage class="user-avatar-item-img" :src="author.avatar" alt="avatar" />
</div>
<div v-if="isMobile" class="info-content-header">
<div class="user-name">
@@ -94,80 +94,10 @@
</div>
</div>
<div v-if="lottery" class="prize-container">
<div class="prize-content">
<div class="prize-info">
<div class="prize-info-left">
<div class="prize-icon">
<img
class="prize-icon-img"
v-if="lottery.prizeIcon"
:src="lottery.prizeIcon"
alt="prize"
/>
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
</div>
<div class="prize-name">{{ lottery.prizeDescription }}</div>
<div class="prize-count">x {{ lottery.prizeCount }}</div>
</div>
<div class="prize-end-time prize-info-right">
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
<div class="prize-end-time-value">{{ countdown }}</div>
<div v-if="!isMobile" class="join-prize-button-container-desktop">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">参与抽奖</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
</div>
<div v-if="isMobile" class="join-prize-button-container-mobile">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">参与抽奖</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
<div class="prize-member-container">
<img
v-for="p in lotteryParticipants"
:key="p.id"
class="prize-member-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
<i class="fas fa-medal medal-icon"></i>
<span class="prize-member-winner-name">获奖者: </span>
<img
v-for="w in lotteryWinners"
:key="w.id"
class="prize-member-avatar"
:src="w.avatar"
alt="avatar"
@click="gotoUser(w.id)"
/>
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
{{ lotteryWinners[0].username }}
</div>
</div>
</div>
</div>
<PostLottery v-if="lottery" :lottery="lottery" :post-id="postId" @refresh="refreshPost" />
<ClientOnly>
<PostPoll v-if="poll" :poll="poll" :post-id="postId" @refresh="refreshPost" />
</ClientOnly>
<div v-if="closed" class="post-close-container">该帖子已关闭内容仅供阅读无法进行互动</div>
<ClientOnly>
@@ -255,12 +185,13 @@ import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import PostLottery from '~/components/PostLottery.vue'
import PostPoll from '~/components/PostPoll.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import TimeManager from '~/utils/time'
import { useRouter } from 'vue-router'
import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components'
@@ -272,7 +203,6 @@ const API_BASE_URL = config.public.apiBaseUrl
const route = useRoute()
const postId = route.params.id
const router = useRouter()
const title = ref('')
const author = ref('')
@@ -312,7 +242,6 @@ useHead(() => ({
if (import.meta.client) {
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer)
})
}
@@ -323,44 +252,7 @@ const loggedIn = computed(() => authState.loggedIn)
const isAdmin = computed(() => authState.role === 'ADMIN')
const isAuthor = computed(() => authState.username === author.value.username)
const lottery = ref(null)
const countdown = ref('00:00:00')
let countdownTimer = null
const lotteryParticipants = computed(() => lottery.value?.participants || [])
const lotteryWinners = computed(() => lottery.value?.winners || [])
const lotteryEnded = computed(() => {
if (!lottery.value || !lottery.value.endTime) return false
return new Date(lottery.value.endTime).getTime() <= Date.now()
})
const hasJoined = computed(() => {
if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const updateCountdown = () => {
if (!lottery.value || !lottery.value.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(lottery.value.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).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')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
if (!import.meta.client) return
if (countdownTimer) clearInterval(countdownTimer)
updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000)
}
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const poll = ref(null)
const articleMenuItems = computed(() => {
const items = []
if (isAuthor.value || isAdmin.value) {
@@ -409,7 +301,13 @@ const gatherPostItems = () => {
}
}
const mapComment = (c, parentUserName = '', level = 0) => ({
const mapComment = (
c,
parentUserName = '',
parentUserAvatar = '',
parentUserId = '',
level = 0,
) => ({
id: c.id,
userName: c.author.username,
medal: c.author.displayMedal,
@@ -419,11 +317,15 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
text: c.content,
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, c.author.avatar, c.author.id, level + 1),
),
openReplies: level === 0,
src: c.author.avatar,
iconClick: () => navigateTo(`/users/${c.author.id}`, { replace: true }),
iconClick: () => navigateTo(`/users/${c.author.id}`),
parentUserName: parentUserName,
parentUserAvatar: parentUserAvatar,
parentUserClick: parentUserId ? () => navigateTo(`/users/${parentUserId}`) : null,
})
const getTop = (el) => {
@@ -511,7 +413,7 @@ watchEffect(() => {
rssExcluded.value = data.rssExcluded
postTime.value = TimeManager.format(data.createdAt)
lottery.value = data.lottery || null
if (lottery.value && lottery.value.endTime) startCountdown()
poll.value = data.poll || null
})
// 404 客户端跳转
@@ -802,24 +704,6 @@ const unsubscribePost = async () => {
}
}
const joinLottery = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/lottery/join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
toast.success('已参与抽奖')
await refreshPost()
} else {
toast.error('操作失败')
}
}
const fetchCommentSorts = () => {
return Promise.resolve([
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
@@ -1222,7 +1106,6 @@ onMounted(async () => {
.info-content-text {
font-size: 16px;
line-height: 1.5;
width: 100%;
}
.article-footer-container {
@@ -1264,139 +1147,6 @@ onMounted(async () => {
position: relative;
}
.prize-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.prize-info {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
align-items: center;
}
.join-prize-button-container-mobile {
margin-top: 15px;
margin-bottom: 10px;
}
.prize-icon {
width: 24px;
height: 24px;
}
.default-prize-icon {
font-size: 24px;
opacity: 0.5;
}
.prize-icon-img {
width: 100%;
height: 100%;
}
.prize-name {
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-count {
font-size: 13px;
font-weight: bold;
opacity: 0.7;
margin-left: 10px;
color: var(--primary-color);
}
.prize-end-time {
display: flex;
flex-direction: row;
align-items: center;
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-end-time-title {
font-size: 13px;
opacity: 0.7;
margin-right: 5px;
}
.prize-end-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.prize-info-left,
.prize-info-right {
display: flex;
flex-direction: row;
align-items: center;
}
.join-prize-button {
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.join-prize-button:hover {
background-color: var(--primary-color-hover);
}
.join-prize-button-disabled {
text-align: center;
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
background-color: var(--primary-color-disabled);
opacity: 0.5;
cursor: not-allowed;
}
.prize-member-avatar {
width: 30px;
height: 30px;
margin-left: 3px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
}
.prize-member-winner {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin-top: 10px;
}
.medal-icon {
font-size: 16px;
color: var(--primary-color);
}
.prize-member-winner-name {
font-size: 13px;
opacity: 0.7;
}
@media (max-width: 768px) {
.post-page-main-container {
width: calc(100% - 20px);
@@ -1420,7 +1170,7 @@ onMounted(async () => {
}
.info-content-text {
line-height: 1.3;
line-height: 1.5;
}
.reactions-viewer-item {
@@ -1447,10 +1197,5 @@ onMounted(async () => {
.loading-container {
width: 100%;
}
.join-prize-button,
.join-prize-button-disabled {
margin-left: 0;
}
}
</style>

View File

@@ -15,7 +15,7 @@
<div class="avatar-row">
<!-- label 充当点击区域内部隐藏 input -->
<label class="avatar-container">
<img :src="avatar" class="avatar-preview" alt="avatar" />
<BaseImage :src="avatar" class="avatar-preview" alt="avatar" />
<!-- 半透明蒙层hover 时出现 -->
<div class="avatar-overlay">更换头像</div>
<input type="file" class="avatar-input" accept="image/*" @change="onAvatarChange" />

View File

@@ -7,7 +7,7 @@
<div v-else>
<div class="profile-page-header">
<div class="profile-page-header-avatar">
<img :src="user.avatar" alt="avatar" class="profile-page-header-avatar-img" />
<BaseImage :src="user.avatar" alt="avatar" class="profile-page-header-avatar-img" />
</div>
<div class="profile-page-header-user-info">
<div class="profile-page-header-user-info-name">{{ user.username }}</div>
@@ -72,266 +72,257 @@
</div>
</div>
<div class="profile-tabs">
<div
:class="['profile-tabs-item', { selected: selectedTab === 'summary' }]"
@click="selectedTab = 'summary'"
>
<i class="fas fa-chart-line"></i>
<div class="profile-tabs-item-label">总结</div>
<BaseTabs v-model="selectedTab" :tabs="tabs">
<div v-if="tabLoading" class="tab-loading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
</div>
<div
:class="['profile-tabs-item', { selected: selectedTab === 'timeline' }]"
@click="selectedTab = 'timeline'"
>
<i class="fas fa-clock"></i>
<div class="profile-tabs-item-label">时间线</div>
</div>
<div
:class="['profile-tabs-item', { selected: selectedTab === 'following' }]"
@click="selectedTab = 'following'"
>
<i class="fas fa-user-plus"></i>
<div class="profile-tabs-item-label">关注</div>
</div>
<div
:class="['profile-tabs-item', { selected: selectedTab === 'achievements' }]"
@click="selectedTab = 'achievements'"
>
<i class="fas fa-medal"></i>
<div class="profile-tabs-item-label">勋章</div>
</div>
</div>
<div v-if="tabLoading" class="tab-loading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
</div>
<template v-else>
<div v-if="selectedTab === 'summary'" class="profile-summary">
<div class="total-summary">
<div class="summary-title">统计信息</div>
<div class="total-summary-content">
<div class="total-summary-item">
<div class="total-summary-item-label">访问天数</div>
<div class="total-summary-item-value">{{ user.visitedDays }}</div>
<template v-else>
<div v-if="selectedTab === 'summary'" class="profile-summary">
<div class="total-summary">
<div class="summary-title">统计信息</div>
<div class="total-summary-content">
<div class="total-summary-item">
<div class="total-summary-item-label">访问天数</div>
<div class="total-summary-item-value">{{ user.visitedDays }}</div>
</div>
<div class="total-summary-item">
<div class="total-summary-item-label">已读帖子</div>
<div class="total-summary-item-value">{{ user.readPosts }}</div>
</div>
<div class="total-summary-item">
<div class="total-summary-item-label">已送出的💗</div>
<div class="total-summary-item-value">{{ user.likesSent }}</div>
</div>
<div class="total-summary-item">
<div class="total-summary-item-label">已收到的💗</div>
<div class="total-summary-item-value">{{ user.likesReceived }}</div>
</div>
</div>
<div class="total-summary-item">
<div class="total-summary-item-label">已读帖子</div>
<div class="total-summary-item-value">{{ user.readPosts }}</div>
</div>
<div class="summary-divider">
<div class="hot-reply">
<div class="summary-title">热门回复</div>
<div class="summary-content" v-if="hotReplies.length > 0">
<BaseTimeline :items="hotReplies">
<template #item="{ item }">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
<template v-if="item.comment.parentComment">
下对
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
回复了
</template>
<template v-else> 下评论了 </template>
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
<div class="timeline-date">
{{ formatDate(item.comment.createdAt) }}
</div>
</template>
</BaseTimeline>
</div>
<div v-else>
<div class="summary-empty">暂无热门回复</div>
</div>
</div>
<div class="total-summary-item">
<div class="total-summary-item-label">已送出的💗</div>
<div class="total-summary-item-value">{{ user.likesSent }}</div>
<div class="hot-topic">
<div class="summary-title">热门话题</div>
<div class="summary-content" v-if="hotPosts.length > 0">
<BaseTimeline :items="hotPosts">
<template #item="{ item }">
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ stripMarkdown(item.post.snippet) }}
</div>
<div class="timeline-date">
{{ formatDate(item.post.createdAt) }}
</div>
</template>
</BaseTimeline>
</div>
<div v-else>
<div class="summary-empty">暂无热门话题</div>
</div>
</div>
<div class="total-summary-item">
<div class="total-summary-item-label">已收到的💗</div>
<div class="total-summary-item-value">{{ user.likesReceived }}</div>
<div class="hot-tag">
<div class="summary-title">TA创建的tag</div>
<div class="summary-content" v-if="hotTags.length > 0">
<BaseTimeline :items="hotTags">
<template #item="{ item }">
<span class="timeline-link" @click="gotoTag(item.tag)">
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
</span>
<div class="timeline-snippet" v-if="item.tag.description">
{{ item.tag.description }}
</div>
<div class="timeline-date">
{{ formatDate(item.tag.createdAt) }}
</div>
</template>
</BaseTimeline>
</div>
<div v-else>
<div class="summary-empty">暂无标签</div>
</div>
</div>
</div>
</div>
<div class="summary-divider">
<div class="hot-reply">
<div class="summary-title">热门回复</div>
<div class="summary-content" v-if="hotReplies.length > 0">
<BaseTimeline :items="hotReplies">
<template #item="{ item }">
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
<div class="timeline-tabs">
<div
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
@click="timelineFilter = 'all'"
>
全部
</div>
<div
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
@click="timelineFilter = 'articles'"
>
文章
</div>
<div
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
@click="timelineFilter = 'comments'"
>
评论和回复
</div>
</div>
<BasePlaceholder
v-if="filteredTimelineItems.length === 0"
text="暂无时间线"
icon="fas fa-inbox"
/>
<div class="timeline-list">
<BaseTimeline :items="filteredTimelineItems">
<template #item="{ item }">
<template v-if="item.type === 'post'">
发布了文章
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'comment'">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
<template v-if="item.comment.parentComment">
下对
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
回复了
</template>
<template v-else> 下评论了 </template>
下评论了
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
<div class="timeline-date">
{{ formatDate(item.comment.createdAt) }}
</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
</BaseTimeline>
</div>
<div v-else>
<div class="summary-empty">暂无热门回复</div>
</div>
</div>
<div class="hot-topic">
<div class="summary-title">热门话题</div>
<div class="summary-content" v-if="hotPosts.length > 0">
<BaseTimeline :items="hotPosts">
<template #item="{ item }">
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
<template v-else-if="item.type === 'reply'">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ stripMarkdown(item.post.snippet) }}
</div>
<div class="timeline-date">
{{ formatDate(item.post.createdAt) }}
</div>
下对
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
回复了
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
</BaseTimeline>
</div>
<div v-else>
<div class="summary-empty">暂无热门话题</div>
</div>
</div>
<div class="hot-tag">
<div class="summary-title">TA创建的tag</div>
<div class="summary-content" v-if="hotTags.length > 0">
<BaseTimeline :items="hotTags">
<template #item="{ item }">
<template v-else-if="item.type === 'tag'">
创建了标签
<span class="timeline-link" @click="gotoTag(item.tag)">
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
</span>
<div class="timeline-snippet" v-if="item.tag.description">
{{ item.tag.description }}
</div>
<div class="timeline-date">
{{ formatDate(item.tag.createdAt) }}
</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
</BaseTimeline>
</div>
<div v-else>
<div class="summary-empty">暂无标签</div>
</div>
</template>
</BaseTimeline>
</div>
</div>
</div>
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
<div class="timeline-tabs">
<div
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
@click="timelineFilter = 'all'"
>
全部
<div v-else-if="selectedTab === 'following'" class="follow-container">
<div class="follow-tabs">
<div
:class="['follow-tab-item', { selected: followTab === 'followers' }]"
@click="followTab = 'followers'"
>
关注者
</div>
<div
:class="['follow-tab-item', { selected: followTab === 'following' }]"
@click="followTab = 'following'"
>
正在关注
</div>
</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 class="follow-list">
<UserList v-if="followTab === 'followers'" :users="followers" />
<UserList v-else :users="followings" />
</div>
</div>
<BasePlaceholder
v-if="filteredTimelineItems.length === 0"
text="暂无时间线"
icon="fas fa-inbox"
/>
<div class="timeline-list">
<BaseTimeline :items="filteredTimelineItems">
<template #item="{ item }">
<template v-if="item.type === 'post'">
发布了文章
<div v-else-if="selectedTab === 'favorites'" class="favorites-container">
<div v-if="favoritePosts.length > 0">
<BaseTimeline :items="favoritePosts">
<template #item="{ item }">
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'comment'">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
下评论了
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'reply'">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
下对
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
回复了
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'tag'">
创建了标签
<span class="timeline-link" @click="gotoTag(item.tag)">
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
</span>
<div class="timeline-snippet" v-if="item.tag.description">
{{ item.tag.description }}
<div class="timeline-snippet">
{{ stripMarkdown(item.post.snippet) }}
</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
<div class="timeline-date">{{ formatDate(item.post.createdAt) }}</div>
</template>
</template>
</BaseTimeline>
</div>
</div>
<div v-else-if="selectedTab === 'following'" class="follow-container">
<div class="follow-tabs">
<div
:class="['follow-tab-item', { selected: followTab === 'followers' }]"
@click="followTab = 'followers'"
>
关注者
</BaseTimeline>
</div>
<div
:class="['follow-tab-item', { selected: followTab === 'following' }]"
@click="followTab = 'following'"
>
正在关注
<div v-else>
<BasePlaceholder text="暂无收藏文章" icon="fas fa-inbox" />
</div>
</div>
<div class="follow-list">
<UserList v-if="followTab === 'followers'" :users="followers" />
<UserList v-else :users="followings" />
</div>
</div>
<div v-else-if="selectedTab === 'achievements'" class="achievements-container">
<AchievementList :medals="medals" :can-select="isMine" />
</div>
</template>
<div v-else-if="selectedTab === 'achievements'" class="achievements-container">
<AchievementList :medals="medals" :can-select="isMine" />
</div>
</template>
</BaseTabs>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import AchievementList from '~/components/AchievementList.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BaseTabs from '~/components/BaseTabs.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import UserList from '~/components/UserList.vue'
import { toast } from '~/main'
@@ -346,13 +337,13 @@ definePageMeta({
alias: ['/users/:id/'],
})
const route = useRoute()
const router = useRouter()
const username = route.params.id
const user = ref({})
const hotPosts = ref([])
const hotReplies = ref([])
const hotTags = ref([])
const favoritePosts = ref([])
const timelineItems = ref([])
const timelineFilter = ref('all')
const filteredTimelineItems = computed(() => {
@@ -370,10 +361,17 @@ const subscribed = ref(false)
const isLoading = ref(true)
const tabLoading = ref(false)
const selectedTab = ref(
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
['summary', 'timeline', 'following', 'favorites', 'achievements'].includes(route.query.tab)
? route.query.tab
: 'summary',
)
const tabs = [
{ key: 'summary', label: '总结', icon: 'fas fa-chart-line' },
{ key: 'timeline', label: '时间线', icon: 'fas fa-clock' },
{ key: 'following', label: '关注', icon: 'fas fa-user-plus' },
{ key: 'favorites', label: '收藏', icon: 'fas fa-bookmark' },
{ key: 'achievements', label: '勋章', icon: 'fas fa-medal' },
]
const followTab = ref('followers')
const levelInfo = computed(() => {
@@ -407,7 +405,7 @@ const fetchUser = async () => {
user.value = data
subscribed.value = !!data.subscribed
} else if (res.status === 404) {
router.replace('/404')
navigateTo('/404')
}
}
@@ -473,6 +471,16 @@ const fetchFollowUsers = async () => {
followings.value = followingRes.ok ? await followingRes.json() : []
}
const fetchFavorites = async () => {
const res = await fetch(`${API_BASE_URL}/api/users/${username}/subscribed-posts`)
if (res.ok) {
const data = await res.json()
favoritePosts.value = data.map((p) => ({ icon: 'fas fa-bookmark', post: p }))
} else {
favoritePosts.value = []
}
}
const loadSummary = async () => {
tabLoading.value = true
await fetchSummary()
@@ -491,6 +499,12 @@ const loadFollow = async () => {
tabLoading.value = false
}
const loadFavorites = async () => {
tabLoading.value = true
await fetchFavorites()
tabLoading.value = false
}
const fetchAchievements = async () => {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
if (res.ok) {
@@ -558,7 +572,7 @@ const sendMessage = async () => {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
})
const result = await response.json()
router.push(`/message-box/${result.conversationId}`)
navigateTo(`/message-box/${result.conversationId}`)
} catch (e) {
toast.error('无法发起私信')
console.error(e)
@@ -579,6 +593,8 @@ const init = async () => {
await loadTimeline()
} else if (selectedTab.value === 'following') {
await loadFollow()
} else if (selectedTab.value === 'favorites') {
await loadFavorites()
} else if (selectedTab.value === 'achievements') {
await loadAchievements()
}
@@ -592,11 +608,13 @@ const init = async () => {
onMounted(init)
watch(selectedTab, async (val) => {
// router.replace({ query: { ...route.query, tab: val } })
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
if (val === 'timeline' && timelineItems.value.length === 0) {
await loadTimeline()
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
await loadFollow()
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
await loadFavorites()
} else if (val === 'achievements' && medals.value.length === 0) {
await loadAchievements()
}
@@ -750,7 +768,7 @@ watch(selectedTab, async (val) => {
font-size: 14px;
}
.profile-tabs {
:deep(.base-tabs-header) {
position: sticky;
top: calc(var(--header-height) + 1px);
z-index: 200;
@@ -764,7 +782,7 @@ watch(selectedTab, async (val) => {
backdrop-filter: var(--blur-10);
}
.profile-tabs-item {
:deep(.base-tabs-item) {
display: flex;
flex: 0 0 auto;
flex-direction: row;
@@ -777,7 +795,7 @@ watch(selectedTab, async (val) => {
white-space: nowrap;
}
.profile-tabs-item.selected {
:deep(.base-tabs-item.selected) {
color: var(--primary-color);
border-bottom: 2px solid var(--primary-color);
}
@@ -901,6 +919,10 @@ watch(selectedTab, async (val) => {
.follow-container {
}
.favorites-container {
padding: 10px;
}
.follow-tabs {
display: flex;
flex-direction: row;
@@ -932,7 +954,7 @@ watch(selectedTab, async (val) => {
height: 100px;
}
.profile-tabs-item {
:deep(.base-tabs-item) {
width: 100px;
}

View File

@@ -0,0 +1,18 @@
// plugins/echarts.client.ts
import { defineNuxtPlugin } from 'nuxt/app'
import VueECharts from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
DataZoomComponent,
TitleComponent,
} from 'echarts/components'
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('VChart', VueECharts)
})

View File

@@ -1,5 +1,11 @@
// markdown.js
import hljs from 'highlight.js/lib/common'
import MarkdownIt from 'markdown-it'
import sanitizeHtml from 'sanitize-html'
import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
// 动态切换 hljs 主题(保持你原有逻辑)
if (typeof window !== 'undefined') {
const theme =
document.documentElement.dataset.theme ||
@@ -10,10 +16,8 @@ if (typeof window !== 'undefined') {
import('highlight.js/styles/atom-one-light.css')
}
}
import MarkdownIt from 'markdown-it'
import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
/** @section 自定义插件:@mention */
function mentionPlugin(md) {
const mentionReg = /^@\[([^\]]+)\]/
function mention(state, silent) {
@@ -27,6 +31,7 @@ function mentionPlugin(md) {
['href', `/users/${match[1]}`],
['target', '_blank'],
['class', 'mention-link'],
['rel', 'noopener noreferrer'],
]
const text = state.push('text', '', 0)
text.content = `@${match[1]}`
@@ -38,6 +43,7 @@ function mentionPlugin(md) {
md.inline.ruler.before('emphasis', 'mention', mention)
}
/** @section 自定义插件:贴吧表情 :tieba123: */
function tiebaEmojiPlugin(md) {
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
const name = tokens[idx].content
@@ -60,7 +66,7 @@ function tiebaEmojiPlugin(md) {
})
}
// 链接在新窗口打开
/** @section 链接外开 */
function linkPlugin(md) {
const defaultRender =
md.renderer.rules.link_open ||
@@ -74,7 +80,6 @@ function linkPlugin(md) {
if (hrefIndex >= 0) {
const href = token.attrs[hrefIndex][1]
// 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer"
if (href.startsWith('http://') || href.startsWith('https://')) {
token.attrPush(['target', '_blank'])
token.attrPush(['rel', 'noopener noreferrer'])
@@ -85,11 +90,15 @@ function linkPlugin(md) {
}
}
/** @section MarkdownIt 实例:开启 HTML但配合强净化 */
const md = new MarkdownIt({
html: false,
html: true,
linkify: true,
breaks: true,
highlight: (str, lang) => {
if (lang === 'mermaid') {
return `<pre class="mermaid">${str}</pre>`
}
let code = ''
if (lang && hljs.getLanguage(lang)) {
code = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
@@ -104,12 +113,113 @@ const md = new MarkdownIt({
},
})
const md2TextRender = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
})
md.use(mentionPlugin)
md.use(tiebaEmojiPlugin)
md.use(linkPlugin) // 添加链接插件
md.use(linkPlugin)
/** @section sanitize-html 配置:只白名单需要的标签/属性/类名 */
const SANITIZE_CFG = {
// 允许的标签(包含你代码块里用到的 button/div
allowedTags: [
'a',
'p',
'div',
'span',
'pre',
'code',
'button',
'img',
'br',
'hr',
'blockquote',
'strong',
'em',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'thead',
'tbody',
'tr',
'td',
'th',
'video',
'source',
],
// 允许的属性
allowedAttributes: {
a: ['href', 'name', 'target', 'rel', 'class'],
img: ['src', 'alt', 'title', 'width', 'height', 'class'],
div: ['class'],
span: ['class'],
pre: ['class'],
code: ['class'],
button: ['class'],
video: [
'controls',
'autoplay',
'muted',
'loop',
'playsinline',
'poster',
'preload',
'width',
'height',
'crossorigin',
],
source: ['src', 'type'],
},
// 允许的类名(保留你的样式钩子)
allowedClasses: {
a: ['mention-link'],
img: ['emoji'],
pre: ['code-block', 'mermaid'],
div: ['line-numbers', 'line-number'],
code: ['hljs', /^language-/],
button: ['copy-code-btn'],
},
// 允许的协议(视频可能是 blob: / data:
allowedSchemes: ['http', 'https', 'data', 'blob'],
allowProtocolRelative: false,
// 统一移除所有 on* 事件、style 等(默认就会清理)
transformTags: {
// 没写 controls 的 video强制加上避免静默自动播放
video: (tagName, attribs) => {
const attrs = { ...attribs }
if (!('controls' in attrs)) attrs.controls = 'controls'
// 安全建议:若允许 autoplay默认要求 muted
if ('autoplay' in attrs && !('muted' in attrs)) {
attrs.muted = 'muted'
}
return { tagName, attribs: attrs }
},
},
}
/** @section 渲染 & 事件 */
export function renderMarkdown(text) {
return md.render(text || '')
const raw = md.render(text || '')
const html = sanitizeHtml(raw, SANITIZE_CFG)
if (typeof window !== 'undefined') {
setTimeout(async () => {
const mermaid = await import('mermaid')
mermaid.default.initialize({ startOnLoad: false })
mermaid.default.run({ nodes: document.querySelectorAll('.mermaid') })
})
}
return html
}
export function handleMarkdownClick(e) {
@@ -124,20 +234,16 @@ export function handleMarkdownClick(e) {
}
}
/** @section 纯文本提取(保持你原有“统一正则法”) */
export function stripMarkdown(text) {
const html = md.render(text || '')
// 统一使用正则表达式方法,确保服务端和客户端行为一致
const html = md2TextRender.render(text || '')
let plainText = html.replace(/<[^>]+>/g, '')
// 标准化空白字符处理
plainText = plainText
.replace(/\r\n/g, '\n') // Windows换行符转为Unix格式
.replace(/\r/g, '\n') // 旧Mac换行符转为Unix格式
.replace(/[ \t]+/g, ' ') // 合并空格和制表符为单个空格
.replace(/\n{3,}/g, '\n\n') // 最多保留两个连续换行(一个空行)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
return plainText
}
@@ -146,6 +252,5 @@ export function stripMarkdownLength(text, length) {
if (!length || plain.length <= length) {
return plain
}
// 截断并加省略号
return plain.slice(0, length) + '...'
}

View File

@@ -25,6 +25,9 @@ const iconMap = {
POINT_REDEEM: 'fas fa-gift',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
POLL_VOTE: 'fas fa-square-poll-vertical',
POLL_RESULT_OWNER: 'fas fa-flag-checkered',
POLL_RESULT_PARTICIPANT: 'fas fa-flag-checkered',
MENTION: 'fas fa-at',
POST_DELETED: 'fas fa-trash',
POST_FEATURED: 'fas fa-star',
@@ -210,6 +213,21 @@ function createFetchNotifications() {
}
},
})
} else if (
n.type === 'POLL_VOTE' ||
n.type === 'POLL_RESULT_OWNER' ||
n.type === 'POLL_RESULT_PARTICIPANT'
) {
arr.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED' || n.type === 'USER_ACTIVITY') {
arr.push({
...n,

View File

@@ -4,6 +4,17 @@ export default class TimeManager {
if (Number.isNaN(date.getTime())) return ''
const now = new Date()
const diffMs = now.getTime() - date.getTime()
if (diffMs >= 0 && diffMs < 60 * 1000) {
return '刚刚'
}
if (diffMs >= 0 && diffMs < 60 * 60 * 1000) {
const mins = Math.floor(diffMs / 60_000)
return `${mins || 1}分钟前`
}
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)

View File

@@ -16,6 +16,7 @@ export function createVditor(editorId, options = {}) {
const { placeholder = '', preview = {}, input, after } = options
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const fetchMentions = async (value) => {
if (!value) {
@@ -76,11 +77,11 @@ export function createVditor(editorId, options = {}) {
const list = await fetchMentions(key)
return list.map((u) => ({
value: `@[${u.username}]`,
html: `<img src="${u.avatar}" /> @${u.username}`,
html: `<BaseImage src="${u.avatar}" /> @${u.username}`,
}))
},
},
vditorPostCitation(API_BASE_URL),
vditorPostCitation(API_BASE_URL, WEBSITE_BASE_URL),
],
},
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',

View File

@@ -10,7 +10,7 @@ async function searchPost(apiBaseUrl, keyword) {
})
}
export default (apiBaseUrl) => {
export default (apiBaseUrl, websiteBaseUrl) => {
return {
key: '#',
hint: async (keyword) => {
@@ -22,8 +22,8 @@ export default (apiBaseUrl) => {
let value = ''
return (
body.map((item) => ({
value: `[${item.title}](/posts/${item.id})`,
html: `<div>${item.title}</div>`,
value: `[🔗${item.title}](${websiteBaseUrl}/posts/${item.id})`,
html: `<div><i class="fas fa-link"></i> ${item.title}</div>`,
})) ?? []
)
} else {

78
package-lock.json generated
View File

@@ -8,45 +8,6 @@
"name": "openisle",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-regex": "^6.1.0",
"ansi-styles": "^6.2.1",
"braces": "^3.0.3",
"chalk": "^5.5.0",
"cli-cursor": "^5.0.0",
"cli-truncate": "^4.0.0",
"colorette": "^2.0.20",
"commander": "^14.0.0",
"debug": "^4.4.1",
"emoji-regex": "^10.4.0",
"environment": "^1.1.0",
"eventemitter3": "^5.0.1",
"fill-range": "^7.1.1",
"get-east-asian-width": "^1.3.0",
"is-fullwidth-code-point": "^4.0.0",
"is-number": "^7.0.0",
"lilconfig": "^3.1.3",
"listr2": "^9.0.1",
"log-update": "^6.1.0",
"micromatch": "^4.0.8",
"mimic-function": "^5.0.1",
"ms": "^2.1.3",
"nano-spawn": "^1.0.2",
"onetime": "^7.0.0",
"picomatch": "^2.3.1",
"pidtree": "^0.6.0",
"restore-cursor": "^5.1.0",
"rfdc": "^1.4.1",
"signal-exit": "^4.1.0",
"slice-ansi": "^5.0.0",
"string-argv": "^0.3.2",
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"to-regex-range": "^5.0.1",
"wrap-ansi": "^9.0.0",
"yaml": "^2.8.1"
},
"devDependencies": {
"husky": "^9.1.7",
"lint-staged": "^16.1.5",
@@ -57,6 +18,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz",
"integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
@@ -72,6 +34,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -84,6 +47,7 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -96,6 +60,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -108,6 +73,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
"integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
@@ -120,6 +86,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
@@ -135,6 +102,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
"integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^5.0.0",
@@ -151,12 +119,14 @@
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz",
"integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
@@ -166,6 +136,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -183,12 +154,14 @@
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"dev": true,
"license": "MIT"
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -201,12 +174,14 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true,
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -219,6 +194,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -247,6 +223,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -259,6 +236,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -268,6 +246,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -308,6 +287,7 @@
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz",
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cli-truncate": "^4.0.0",
@@ -325,6 +305,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
@@ -344,6 +325,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz",
"integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.0.0"
@@ -359,6 +341,7 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz",
"integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
@@ -375,6 +358,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -388,6 +372,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -400,12 +385,14 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nano-spawn": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz",
"integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.17"
@@ -418,6 +405,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
@@ -433,6 +421,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -445,6 +434,7 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
"dev": true,
"license": "MIT",
"bin": {
"pidtree": "bin/pidtree.js"
@@ -473,6 +463,7 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
@@ -489,12 +480,14 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -507,6 +500,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
"integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.0.0",
@@ -523,6 +517,7 @@
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.19"
@@ -532,6 +527,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
@@ -549,6 +545,7 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -564,6 +561,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -576,6 +574,7 @@
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
"integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
@@ -593,6 +592,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View File

@@ -2,7 +2,7 @@
"name": "openisle",
"version": "1.0.0",
"private": true,
"description": "<p align=\"center\"> <img alt=\"OpenIsle\" src=\"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png\" width=\"200\"> <br><br> 高效的开源社区前后端端平台 <br><br> <a href=\"LICENSE\"><img src=\"https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square\"></a> </p>",
"description": "<p align=\"center\"> <BaseImage alt=\"OpenIsle\" src=\"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png\" width=\"200\"> <br><br> 高效的开源社区前后端端平台 <br><br> <a href=\"LICENSE\"><BaseImage src=\"https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square\"></a> </p>",
"author": "nagisa77",
"license": "ISC",
"homepage": "https://www.open-isle.com",