Compare commits

...

39 Commits

Author SHA1 Message Date
Tim
9c5a49a47f fix: 完善提案通知流程,防止重复通知,提案成功自动插入分类 2025-10-23 15:29:55 +08:00
Tim
2271bbbd1d feat: 分类提案首页icon 2025-10-23 14:30:57 +08:00
Tim
d6470e04fc fix: 分类提案投票UI 2025-10-23 14:28:07 +08:00
Tim
4db35a4531 fix: 解决后台报错的问题 2025-10-23 13:43:39 +08:00
Tim
1906ffd8aa Update CONTRIBUTING.md to simplify instructions
Removed redundant build command from contributing guide.
2025-10-23 12:30:37 +08:00
Tim
426884385f fix: 更新巡航指南 2025-10-23 12:27:15 +08:00
Tim
8193c92c91 fix: 新增提案规则,新增自定义后端 2025-10-23 12:11:22 +08:00
Tim
90649b422d feat: 补充提案规则ui 2025-10-22 20:39:54 +08:00
Tim
67efb64ccc fix: 分类提案简化用户输入 2025-10-22 20:33:24 +08:00
Tim
23d8eafc08 fix: 删除替换为icon-park 2025-10-22 20:15:34 +08:00
Tim
d1cc16e31e Merge remote-tracking branch 'origin/main' into feat_category_proposal 2025-10-22 19:54:17 +08:00
Tim
0f1c45b155 Merge pull request #1078 from xuemian168/patch-1
Enhance security policy with detailed guidelines
2025-10-22 10:19:26 +08:00
XueMian (ICT.RUN)
8ed11df99c Enhance security policy with detailed guidelines
Expanded the security policy to include detailed reporting procedures, security considerations, and best practices for contributors.
2025-10-22 10:10:57 +10:00
Tim
a9608cc706 Merge pull request #1074 from nagisa77/feature/search_bar
Feature/search bar
2025-10-17 17:43:04 +08:00
Tim
232f40151b fix: 修改css冲突 2025-10-17 17:42:12 +08:00
Tim
3b3f99754d fix: 搜索focus 2025-10-17 17:33:34 +08:00
Tim
e14566ee66 fix: 搜索提示 2025-10-17 17:01:55 +08:00
Tim
892312c6d4 fix: 搜索框 2025-10-17 16:59:40 +08:00
Tim
dfb31771ff feat: searchbar集成到header 2025-10-17 16:54:03 +08:00
Tim
bf7df629cc Merge pull request #1073 from nagisa77/feature/ui_fix
fix: avatar 以及 auth 重构
2025-10-17 15:11:55 +08:00
Tim
f17b644a9b fix: avatar 以及 auth 重构 2025-10-17 15:10:43 +08:00
Tim
61f8fa4bb7 fix: 新增右间距 2025-10-17 12:19:40 +08:00
Tim
43929bcdc5 Merge pull request #1072 from nagisa77/feature/give_some_money
fix: 移动端ui适配
2025-10-17 12:02:03 +08:00
Tim
6aecb4f583 fix: 移动端ui适配 2025-10-17 12:01:32 +08:00
Tim
0d2e6a9505 Merge pull request #1065 from nagisa77/feature/give_some_money
打赏功能实现
2025-10-17 11:36:34 +08:00
Tim
b2d70b9bde Merge pull request #1069 from nagisa77/codex/add-reinitialize-command-to-contributing.md
docs: document docker compose volume reset workflow
2025-10-17 11:36:15 +08:00
Tim
d914579d64 fix: Donate历史 2025-10-17 11:35:29 +08:00
Tim
8643446d8b feat: 赞赏后台 2025-10-17 11:24:19 +08:00
Tim
2db958f8c9 fix: 赞赏ui 2025-10-17 10:52:05 +08:00
Tim
fa29d255c9 Merge pull request #1067 from smallclover/main
tieba表情函数抽成共通
2025-10-16 22:35:43 +08:00
smallclover
b3fa5e2bef 修复已读 2025-10-16 21:19:13 +09:00
smallclover
a7ef4380d8 问题修复
1.修复网页模式下,markdown代码过长
2.修复网页模实下,按钮文字换行
3.修复网页模式下,消息换行
2025-10-16 21:13:56 +09:00
Tim
39d954d98a fix: 继续做UI工作 2025-10-16 18:11:27 +08:00
Tim
596d1558a2 docs: add compose volume reset instructions 2025-10-16 10:13:07 +08:00
tim
ce04570efb feat: 新增itemGroup 2025-10-16 09:58:57 +08:00
smallclover
215c7077d5 tieba表情函数抽成共通 2025-10-15 22:47:48 +09:00
LuoQianhong
c9854e1840 Merge branch 'main' into feat/category_proposal 2025-09-29 09:26:22 +08:00
sivdead
3da5d24488 fix: 修复代码合并问题 2025-09-25 18:17:26 +08:00
sivdead
76962d6d1c feat: 添加分类提案功能,包括提案表单和相关后端逻辑 2025-09-25 17:47:46 +08:00
67 changed files with 1992 additions and 284 deletions

View File

@@ -1,5 +1,6 @@
- [前置工作](#前置工作)
- [前端极速调试Docker 全量环境)](#前端极速调试docker-全量环境)
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
- [启动后端服务](#启动后端服务)
- [本地 IDEA](#本地-idea)
- [配置环境变量](#配置环境变量)
@@ -39,13 +40,6 @@ cd OpenIsle
```
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
2. 启动 Dev Profile
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev build
```
```shell
docker compose \
-f docker/docker-compose.yaml \
@@ -53,8 +47,8 @@ cd OpenIsle
--profile dev up -d
```
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
修改代码,可以强制重新创建所有容器,执行:
修改前端代码,页面会热更新。
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA采用IDEA编译运行也可以哦。
```shell
docker compose \
@@ -73,8 +67,49 @@ cd OpenIsle
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
```
5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v
```
`-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
<a id="dev-dev_local_backend-guide"></a>
### 🧭 dev 与 dev_local_backend 巡航指南
在需要本地 IDE 启动后端、而容器只提供 MySQL、Redis、RabbitMQ、OpenSearch 等依赖时,可切换到 `dev_local_backend` Profile
```bash
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev_local_backend up -d
```
> [!TIP]
> 该 Profile 不会启动 Docker 内的 Spring Boot 服务,`frontend_dev_local_backend` 会通过 `host.docker.internal` 访问你本机正在运行的后端。非常适合用 IDEA/VS Code 调试 Java 服务的场景!
| 想要的体验 | 推荐 Profile | 会启动的关键容器 | 备注 |
| --- | --- | --- | --- |
| 🚀 一键启动前后端 | `dev` | `springboot`、`frontend_dev`、`mysql`… | 纯容器内跑全链路,省心省力 |
| 🛠️ IDE 启动后端 + 容器托管依赖 | `dev_local_backend` | `frontend_dev_local_backend`、`mysql`、`redis`… | 记得本地后端监听 `8080`/`8082` 等端口 |
切换 Profile 时,请先停掉当前组合再启动另一组,避免端口占用或容器命名冲突:
```bash
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
# 或者
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev_local_backend down
```
常见小贴士:
- 🧹 需要彻底清理依赖时,别忘了追加 `-v` 清除持久化数据卷。
- 🪄 仅切换 Profile 时通常无需重新 `build`,除非你更新了镜像依赖。
- 🧪 如需确认前端容器访问的是本机后端,可在 IDE 控制台查看请求日志或执行 `curl http://localhost:8080/actuator/health` 进行自检。
## 启动后端服务
启动后端服务有多种方式,选择一种即可。
@@ -104,6 +139,17 @@ IDEA 打开 `backend/` 文件夹。
LOG_LEVEL=DEBUG
```
> [!WARNING]
> 如果你通过 `dev_local_backend` Profile 启动了数据库/缓存等依赖,却让后端由 IDEA 在宿主机运行,请务必将 `open-isle.env`(或 IDEA 的环境变量面板)中的主机名改成 `localhost`
>
> ```ini
> MYSQL_HOST=localhost
> REDIS_HOST=localhost
> RABBITMQ_HOST=localhost
> ```
>
> 对应的容器端口均已映射到宿主机,无需额外配置。若仍保留默认的 `mysql`、`redis`、`rabbitmq`IDEA 将尝试解析容器网络内的别名而导致连接失败。
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
![配置数据库](assets/contributing/backend_img_5.png)

176
SECURITY.md Normal file
View File

@@ -0,0 +1,176 @@
# Security Policy
## Supported Versions
We take the security of OpenIsle seriously. The following versions are currently being supported with security updates:
| Version | Supported |
| ------- | ------------------ |
| 0.0.x | :white_check_mark: |
## Reporting a Vulnerability
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
### How to Report a Security Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via one of the following methods:
1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information)
2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new
### What to Include in Your Report
To help us better understand the nature and scope of the issue, please include as much of the following information as possible:
- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.)
- Full paths of source file(s) related to the manifestation of the issue
- The location of the affected source code (tag/branch/commit or direct URL)
- Any special configuration required to reproduce the issue
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue, including how an attacker might exploit it
### Response Timeline
- **Initial Response**: We will acknowledge your report within 48 hours
- **Status Updates**: We will provide status updates at least every 5 business days
- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure
### What to Expect
After you submit a report:
1. We will confirm receipt of your vulnerability report and may ask for additional information
2. We will investigate the issue and determine its impact and severity
3. We will work on a fix and coordinate disclosure timing with you
4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous)
## Security Considerations for Deployment
### Authentication & Authorization
- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits)
- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control
- **Session Management**: Configure appropriate session timeout values
### Database Security
- Use strong database passwords
- Never expose database ports publicly
- Use database connection encryption when available
- Regularly backup your database
### API Security
- Enable rate limiting to prevent abuse
- Validate all user inputs on both client and server side
- Use HTTPS in production environments
- Configure CORS properly to restrict origins
### Environment Variables
The following sensitive environment variables should be kept secure:
- `JWT_SECRET` - JWT signing key
- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials
- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials
- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials
- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials
- `WEBPUSH_PRIVATE_KEY` - Web push notification private key
- Database connection strings and credentials
- Cloud storage credentials (Tencent COS)
**Never commit these values to version control or expose them in logs.**
### File Upload Security
- Validate file types and sizes
- Scan uploaded files for malware
- Store uploaded files outside the web root
- Use cloud storage with proper access controls
### Password Security
- Configure password strength requirements via environment variables
- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security)
- Implement account lockout after failed login attempts
### Web Push Notifications
- Keep `WEBPUSH_PRIVATE_KEY` secret and secure
- Only send notifications to users who have explicitly opted in
- Validate notification payloads
### Dependency Management
- Regularly update dependencies to patch known vulnerabilities
- Run `mvn dependency-check:check` to scan for vulnerable dependencies
- Monitor GitHub security advisories for this project
### Production Deployment Checklist
- [ ] Use HTTPS/TLS for all connections
- [ ] Set strong, unique secrets for all environment variables
- [ ] Enable CSRF protection
- [ ] Configure secure headers (CSP, X-Frame-Options, etc.)
- [ ] Disable debug mode and verbose error messages
- [ ] Set up proper logging and monitoring
- [ ] Implement rate limiting and DDoS protection
- [ ] Regular security updates and patches
- [ ] Database backups and disaster recovery plan
- [ ] Restrict admin access to trusted IPs when possible
## Known Security Features
OpenIsle includes the following security features:
- JWT-based authentication with configurable expiration
- OAuth 2.0 integration with major providers
- Password strength validation
- Protection codes for sensitive operations
- Input validation and sanitization
- SQL injection prevention through ORM (JPA/Hibernate)
- XSS protection in Vue.js templates
- CSRF protection (Spring Security)
## Security Best Practices for Contributors
- Never commit credentials, API keys, or secrets
- Follow secure coding practices (OWASP Top 10)
- Validate and sanitize all user inputs
- Use parameterized queries for database operations
- Implement proper error handling without exposing sensitive information
- Write security tests for new features
- Review code for security issues before submitting PRs
## Disclosure Policy
When we receive a security bug report, we will:
1. Confirm the problem and determine affected versions
2. Audit code to find any similar problems
3. Prepare fixes for all supported versions
4. Release patches as soon as possible
We appreciate your help in keeping OpenIsle and its users safe!
## Attribution
We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in:
- Security advisory
- Release notes
- A security hall of fame (if established)
If you prefer to remain anonymous, we will respect your wishes.
## Contact
For any security-related questions or concerns, please reach out through the channels mentioned above.
---
Thank you for helping keep OpenIsle secure!

View File

@@ -19,6 +19,7 @@ JWT_EXPIRATION=2592000000
# === Redis ===
REDIS_HOST=<Redis 地址>
REDIS_PORT=<Redis 端口>
REDIS_PASS=<Redis 密码>
# === Resend ===
RESEND_API_KEY=<你的resend-api-key>

View File

@@ -73,7 +73,9 @@ public class PostController {
req.getStartTime(),
req.getEndTime(),
req.getOptions(),
req.getMultiple()
req.getMultiple(),
req.getProposedName(),
req.getProposalDescription()
);
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());

View File

@@ -0,0 +1,43 @@
package com.openisle.controller;
import com.openisle.dto.DonationRequest;
import com.openisle.dto.DonationResponse;
import com.openisle.service.PointService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts/{postId}/donations")
@RequiredArgsConstructor
public class PostDonationController {
private final PointService pointService;
@GetMapping
@Operation(summary = "List donations", description = "Get recent donations for a post")
@ApiResponse(responseCode = "200", description = "Donation summary")
public DonationResponse list(@PathVariable Long postId) {
return pointService.getPostDonations(postId);
}
@PostMapping
@SecurityRequirement(name = "JWT")
@Operation(summary = "Donate", description = "Donate points to the post author")
@ApiResponse(responseCode = "200", description = "Donation result")
public DonationResponse donate(
@PathVariable Long postId,
@RequestBody DonationRequest req,
Authentication auth
) {
return pointService.donateToPost(auth.getName(), postId, req.getAmount());
}
}

View File

@@ -0,0 +1,16 @@
package com.openisle.dto;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationDto {
private Long userId;
private String username;
private String avatar;
private int amount;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,11 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationRequest {
private int amount;
}

View File

@@ -0,0 +1,15 @@
package com.openisle.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationResponse {
private int totalAmount;
private List<DonationDto> donations = new ArrayList<>();
private Integer balance;
}

View File

@@ -29,4 +29,5 @@ public class PostChangeLogDto {
private LocalDateTime newPinnedAt;
private Boolean oldFeatured;
private Boolean newFeatured;
private Integer amount;
}

View File

@@ -28,4 +28,8 @@ public class PostRequest {
// fields for poll posts
private List<String> options;
private Boolean multiple;
// fields for category proposal posts
private String proposedName;
private String proposalDescription;
}

View File

@@ -0,0 +1,20 @@
package com.openisle.dto;
import com.openisle.model.CategoryProposalStatus;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ProposalDto extends PollDto {
private CategoryProposalStatus proposalStatus;
private String proposedName;
private String description;
private int approveThreshold;
private int quorum;
private LocalDateTime startAt;
private String resultSnapshot;
private String rejectReason;
}

View File

@@ -52,6 +52,8 @@ public class PostChangeLogMapper {
} else if (log instanceof PostFeaturedChangeLog f) {
dto.setOldFeatured(f.isOldFeatured());
dto.setNewFeatured(f.isNewFeatured());
} else if (log instanceof PostDonateChangeLog d) {
dto.setAmount(d.getAmount());
}
return dto;
}

View File

@@ -6,7 +6,9 @@ import com.openisle.dto.LotteryDto;
import com.openisle.dto.PollDto;
import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.ProposalDto;
import com.openisle.dto.ReactionDto;
import com.openisle.model.CategoryProposalPost;
import com.openisle.model.CommentSort;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
@@ -113,26 +115,40 @@ public class PostMapper {
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(Boolean.TRUE.equals(pp.getMultiple()));
dto.setPoll(p);
if (post instanceof CategoryProposalPost cp) {
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
proposalDto.setProposalStatus(cp.getProposalStatus());
proposalDto.setProposedName(cp.getProposedName());
proposalDto.setDescription(cp.getDescription());
proposalDto.setApproveThreshold(cp.getApproveThreshold());
proposalDto.setQuorum(cp.getQuorum());
proposalDto.setStartAt(cp.getStartAt());
proposalDto.setResultSnapshot(cp.getResultSnapshot());
proposalDto.setRejectReason(cp.getRejectReason());
dto.setPoll(proposalDto);
} else if (post instanceof PollPost pp) {
dto.setPoll(buildPollDto(pp, new PollDto()));
}
}
private PollDto buildPollDto(PollPost pollPost, PollDto target) {
target.setOptions(pollPost.getOptions());
target.setVotes(pollPost.getVotes());
target.setEndTime(pollPost.getEndTime());
target.setParticipants(
pollPost.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
.findByPostId(pollPost.getId())
.stream()
.collect(
Collectors.groupingBy(
PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
)
);
target.setOptionParticipants(optionParticipants);
target.setMultiple(Boolean.TRUE.equals(pollPost.getMultiple()));
return target;
}
}

View File

@@ -0,0 +1,59 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.PrimaryKeyJoinColumn;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A specialized post type used for proposing new categories.
* It reuses poll mechanics (participants, votes, endTime) by extending PollPost.
*/
@Entity
@Table(
name = "category_proposal_posts",
indexes = { @Index(name = "idx_category_proposal_posts_status", columnList = "status") }
)
@Getter
@Setter
@NoArgsConstructor
@PrimaryKeyJoinColumn(name = "post_id")
public class CategoryProposalPost extends PollPost {
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING;
@Column(name = "proposed_name", nullable = false, unique = true)
private String proposedName;
@Column(name = "description")
private String description;
// Approval threshold as percentage (0-100), default 60
@Column(name = "approve_threshold", nullable = false)
private int approveThreshold = 60;
// Minimum number of participants required to meet quorum
@Column(name = "quorum", nullable = false)
private int quorum = 10;
// Optional voting start time (end time inherited from PollPost)
@Column(name = "start_at")
private LocalDateTime startAt;
// Snapshot of poll results at finalization (e.g., JSON)
@Column(name = "result_snapshot", columnDefinition = "TEXT")
private String resultSnapshot;
// Reason when proposal is rejected
@Column(name = "reject_reason")
private String rejectReason;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.model;
public enum CategoryProposalStatus {
PENDING,
APPROVED,
REJECTED
}

View File

@@ -46,8 +46,14 @@ public enum NotificationType {
POLL_RESULT_OWNER,
/** A poll you participated in has concluded */
POLL_RESULT_PARTICIPANT,
/** Your category proposal has concluded */
CATEGORY_PROPOSAL_RESULT_OWNER,
/** A category proposal you participated in has concluded */
CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
/** Your post was featured */
POST_FEATURED,
/** Someone donated to your post */
DONATION,
/** You were mentioned in a post or comment */
MENTION,
}

View File

@@ -13,4 +13,6 @@ public enum PointHistoryType {
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD,
DONATE_SENT,
DONATE_RECEIVED,
}

View File

@@ -10,4 +10,5 @@ public enum PostChangeType {
FEATURED,
VOTE_RESULT,
LOTTERY_RESULT,
DONATE,
}

View File

@@ -0,0 +1,19 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_donate_change_logs")
public class PostDonateChangeLog extends PostChangeLog {
@Column(nullable = false)
private int amount;
}

View File

@@ -4,4 +4,5 @@ public enum PostType {
NORMAL,
LOTTERY,
POLL,
PROPOSAL
}

View File

@@ -0,0 +1,19 @@
package com.openisle.repository;
import com.openisle.model.CategoryProposalPost;
import com.openisle.model.CategoryProposalStatus;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryProposalPostRepository extends JpaRepository<CategoryProposalPost, Long> {
List<CategoryProposalPost> findByEndTimeAfterAndProposalStatus(
LocalDateTime now,
CategoryProposalStatus status
);
List<CategoryProposalPost> findByEndTimeBeforeAndProposalStatus(
LocalDateTime now,
CategoryProposalStatus status
);
boolean existsByProposedNameIgnoreCase(String proposedName);
}

View File

@@ -2,11 +2,14 @@ package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.Post;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
@@ -21,4 +24,11 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
List<PointHistory> findByComment(Comment comment);
List<PointHistory> findByPost(Post post);
List<PointHistory> findTop10ByPostAndTypeOrderByCreatedAtDesc(Post post, PointHistoryType type);
@Query(
"SELECT COALESCE(SUM(ph.amount), 0) FROM PointHistory ph WHERE ph.post = :post AND ph.type = :type"
)
Long sumAmountByPostAndType(@Param("post") Post post, @Param("type") PointHistoryType type);
}

View File

@@ -1,5 +1,7 @@
package com.openisle.service;
import com.openisle.dto.DonationDto;
import com.openisle.dto.DonationResponse;
import com.openisle.exception.FieldException;
import com.openisle.model.*;
import com.openisle.repository.*;
@@ -8,8 +10,10 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@@ -20,6 +24,8 @@ public class PointService {
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
private final NotificationService notificationService;
private final PostChangeLogService postChangeLogService;
public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
@@ -272,4 +278,95 @@ public class PointService {
User user = userRepository.findByUsername(userName).orElseThrow();
return recalculateUserPoints(user);
}
@Transactional
public DonationResponse donateToPost(String donorName, Long postId, int amount) {
if (amount <= 0) {
throw new FieldException("amount", "打赏积分必须大于0");
}
User donor = userRepository.findByUsername(donorName).orElseThrow();
Post post = postRepository.findById(postId).orElseThrow();
User author = post.getAuthor();
if (author.getId().equals(donor.getId())) {
throw new FieldException("post", "不能给自己打赏");
}
if (donor.getPoint() < amount) {
throw new FieldException("point", "积分不足");
}
addPoint(donor, -amount, PointHistoryType.DONATE_SENT, post, null, author);
addPoint(author, amount, PointHistoryType.DONATE_RECEIVED, post, null, donor);
notificationService.createNotification(
author,
NotificationType.DONATION,
post,
null,
null,
donor,
null,
String.valueOf(amount)
);
postChangeLogService.recordDonation(post, donor, amount);
DonationResponse response = buildDonationResponse(post);
response.setBalance(donor.getPoint());
return response;
}
public DonationResponse getPostDonations(Long postId) {
Post post = postRepository.findById(postId).orElseThrow();
return buildDonationResponse(post);
}
private DonationResponse buildDonationResponse(Post post) {
List<PointHistory> histories =
pointHistoryRepository.findTop10ByPostAndTypeOrderByCreatedAtDesc(
post,
PointHistoryType.DONATE_RECEIVED
);
List<DonationDto> donations = histories
.stream()
.collect(Collectors.collectingAndThen(Collectors.toMap(
history -> {
User donor = history.getFromUser();
if (donor != null && donor.getId() != null) {
return "user:" + donor.getId();
}
return "history:" + history.getId();
},
history -> {
DonationDto dto = new DonationDto();
User donor = history.getFromUser();
if (donor != null) {
dto.setUserId(donor.getId());
dto.setUsername(donor.getUsername());
dto.setAvatar(donor.getAvatar());
}
dto.setAmount(history.getAmount());
dto.setCreatedAt(history.getCreatedAt());
return dto;
},
(left, right) -> {
left.setAmount(left.getAmount() + right.getAmount());
if (
left.getCreatedAt() == null ||
(right.getCreatedAt() != null && right.getCreatedAt().isAfter(left.getCreatedAt()))
) {
left.setCreatedAt(right.getCreatedAt());
}
return left;
},
java.util.LinkedHashMap::new
), map -> new java.util.ArrayList<>(map.values())));
Long total = pointHistoryRepository.sumAmountByPostAndType(
post,
PointHistoryType.DONATE_RECEIVED
);
int safeTotal = 0;
if (total != null) {
safeTotal = total > Integer.MAX_VALUE ? Integer.MAX_VALUE : total.intValue();
}
DonationResponse response = new DonationResponse();
response.setDonations(donations);
response.setTotalAmount(safeTotal);
return response;
}
}

View File

@@ -115,6 +115,15 @@ public class PostChangeLogService {
logRepository.save(log);
}
public void recordDonation(Post post, User donor, int amount) {
PostDonateChangeLog log = new PostDonateChangeLog();
log.setPost(post);
log.setUser(donor);
log.setType(PostChangeType.DONATE);
log.setAmount(amount);
logRepository.save(log);
}
public void deleteLogsForPost(Post post) {
logRepository.deleteByPost(post);
}

View File

@@ -2,8 +2,8 @@ package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.exception.RateLimitException;
import com.openisle.mapper.PostMapper;
import com.openisle.model.*;
import com.openisle.repository.CategoryProposalPostRepository;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.LotteryPostRepository;
@@ -21,7 +21,6 @@ import com.openisle.service.EmailSender;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -32,7 +31,6 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.PageRequest;
@@ -54,6 +52,7 @@ public class PostService {
private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository;
private final PollPostRepository pollPostRepository;
private final CategoryProposalPostRepository categoryProposalPostRepository;
private final PollVoteRepository pollVoteRepository;
private PublishMode publishMode;
private final NotificationService notificationService;
@@ -71,11 +70,17 @@ public class PostService {
private final PointService pointService;
private final PostChangeLogService postChangeLogService;
private final PointHistoryRepository pointHistoryRepository;
private final CategoryService categoryService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
new ConcurrentHashMap<>();
private final SearchIndexEventPublisher searchIndexEventPublisher;
private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60;
private static final int DEFAULT_PROPOSAL_QUORUM = 10;
private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3;
private static final List<String> DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对");
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -89,6 +94,7 @@ public class PostService {
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
CategoryProposalPostRepository categoryProposalPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
@@ -107,7 +113,8 @@ public class PostService {
PointHistoryRepository pointHistoryRepository,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate,
SearchIndexEventPublisher searchIndexEventPublisher
SearchIndexEventPublisher searchIndexEventPublisher,
CategoryService categoryService
) {
this.postRepository = postRepository;
this.userRepository = userRepository;
@@ -115,6 +122,7 @@ public class PostService {
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.categoryProposalPostRepository = categoryProposalPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
@@ -135,6 +143,7 @@ public class PostService {
this.redisTemplate = redisTemplate;
this.searchIndexEventPublisher = searchIndexEventPublisher;
this.categoryService = categoryService;
}
@EventListener(ApplicationReadyEvent.class)
@@ -160,6 +169,24 @@ public class PostService {
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
}
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus(
now,
CategoryProposalStatus.PENDING
)) {
if (cp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(cp.getId(), future);
}
}
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus(
now,
CategoryProposalStatus.PENDING
)) {
applicationContext.getBean(PostService.class).finalizeProposal(cp.getId());
}
}
public PublishMode getPublishMode() {
@@ -232,10 +259,12 @@ public class PostService {
LocalDateTime startTime,
LocalDateTime endTime,
java.util.List<String> options,
Boolean multiple
Boolean multiple,
String proposedName,
String proposalDescription
) {
// 限制访问次数
boolean limitResult = postRateLimit(username);
boolean limitResult = isPostLimitReached(username);
if (!limitResult) {
throw new RateLimitException("Too many posts");
}
@@ -278,6 +307,25 @@ public class PostService {
pp.setEndTime(endTime);
pp.setMultiple(multiple != null && multiple);
post = pp;
} else if (actualType == PostType.PROPOSAL) {
CategoryProposalPost cp = new CategoryProposalPost();
if (proposedName == null || proposedName.isBlank()) {
throw new IllegalArgumentException("Proposed name required");
}
String normalizedName = proposedName.trim();
if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
}
cp.setProposedName(normalizedName);
cp.setDescription(proposalDescription);
cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
LocalDateTime now = LocalDateTime.now();
cp.setStartAt(now);
cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
cp.setOptions(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS));
cp.setMultiple(false);
post = cp;
} else {
post = new Post();
}
@@ -290,6 +338,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 CategoryProposalPost categoryProposalPost) {
post = categoryProposalPostRepository.save(categoryProposalPost);
} else if (post instanceof PollPost) {
post = pollPostRepository.save((PollPost) post);
} else {
@@ -344,6 +394,12 @@ public class PostService {
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(lp.getId(), future);
} else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(cp.getId(), future);
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
@@ -354,24 +410,110 @@ public class PostService {
if (post.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(post);
}
markPostLimit(author.getUsername());
return post;
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@Transactional
public void finalizeProposal(Long postId) {
scheduledFinalizations.remove(postId);
categoryProposalPostRepository
.findById(postId)
.ifPresent(cp -> {
if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) {
return;
}
int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0;
int approveVotes = 0;
if (cp.getVotes() != null) {
approveVotes = cp.getVotes().getOrDefault(0, 0);
}
boolean quorumMet = totalParticipants >= cp.getQuorum();
int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0;
boolean thresholdMet = approvePercent >= cp.getApproveThreshold();
boolean approved = false;
String rejectReason = null;
if (quorumMet && thresholdMet) {
cp.setProposalStatus(CategoryProposalStatus.APPROVED);
approved = true;
} else {
cp.setProposalStatus(CategoryProposalStatus.REJECTED);
String reason;
if (!quorumMet && !thresholdMet) {
reason = "未达到法定人数且赞成率不足";
} else if (!quorumMet) {
reason = "未达到法定人数";
} else {
reason = "赞成率不足";
}
cp.setRejectReason(reason);
rejectReason = reason;
}
cp.setResultSnapshot(
"approveVotes=" +
approveVotes +
", totalParticipants=" +
totalParticipants +
", approvePercent=" +
approvePercent
);
categoryProposalPostRepository.save(cp);
if (approved) {
categoryService.createCategory(cp.getProposedName(), cp.getDescription(), "star", null);
}
if (cp.getAuthor() != null) {
notificationService.createNotification(
cp.getAuthor(),
NotificationType.CATEGORY_PROPOSAL_RESULT_OWNER,
cp,
null,
approved,
null,
null,
approved ? null : rejectReason
);
}
for (User participant : cp.getParticipants()) {
if (
cp.getAuthor() != null &&
java.util.Objects.equals(participant.getId(), cp.getAuthor().getId())
) {
continue;
}
notificationService.createNotification(
participant,
NotificationType.CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
cp,
null,
approved,
null,
null,
approved ? null : rejectReason
);
}
postChangeLogService.recordVoteResult(cp);
});
}
/**
* 限制发帖频率
* 检查用户是否达到发帖限制
* @param username
* @return
* @return true - 允许发帖false - 已达限制
*/
private boolean postRateLimit(String username) {
private boolean isPostLimitReached(String username) {
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
String result = (String) redisTemplate.opsForValue().get(key);
//最近没有创建过文章
if (StringUtils.isEmpty(result)) {
// 限制频率为5分钟
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
return true;
}
return false;
return StringUtils.isEmpty(result);
}
/**
* 标记用户发帖触发limit计时
* @param username
*/
private void markPostLimit(String username) {
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@@ -450,6 +592,9 @@ public class PostService {
pollPostRepository
.findById(postId)
.ifPresent(pp -> {
if (pp instanceof CategoryProposalPost) {
return;
}
if (pp.isResultAnnounced()) {
return;
}

View File

@@ -13,6 +13,7 @@ spring.jpa.hibernate.ddl-auto=update
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=${REDIS_PORT:6379}
spring.data.redis.database=${REDIS_DATABASE:0}
spring.data.redis.password=${REDIS_PASS: null}
# for jwt
app.jwt.secret=${JWT_SECRET:jwt_sec}

View File

@@ -0,0 +1,25 @@
-- Create table for category proposal posts (subclass of poll_posts)
CREATE TABLE IF NOT EXISTS category_proposal_posts (
post_id BIGINT NOT NULL,
status VARCHAR(50) NOT NULL,
proposed_name VARCHAR(255) NOT NULL,
proposed_slug VARCHAR(255) NOT NULL,
description VARCHAR(255),
approve_threshold INT NOT NULL DEFAULT 60,
quorum INT NOT NULL DEFAULT 10,
start_at DATETIME(6) NULL,
result_snapshot LONGTEXT NULL,
reject_reason VARCHAR(255),
PRIMARY KEY (post_id),
CONSTRAINT fk_category_proposal_posts_parent
FOREIGN KEY (post_id) REFERENCES poll_posts (post_id)
);
CREATE INDEX IF NOT EXISTS idx_category_proposal_posts_status
ON category_proposal_posts (status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_slug
ON category_proposal_posts (proposed_slug);

View File

@@ -0,0 +1,8 @@
ALTER TABLE category_proposal_posts
DROP INDEX idx_category_proposal_posts_slug;
ALTER TABLE category_proposal_posts
DROP COLUMN proposed_slug;
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_name
ON category_proposal_posts (proposed_name);

View File

@@ -76,6 +76,15 @@ class PostControllerTest {
@MockBean
private MedalService medalService;
@MockBean
private CategoryService categoryService;
@MockBean
private TagService tagService;
@MockBean
private PointService pointService;
@MockBean
private com.openisle.repository.PollVoteRepository pollVoteRepository;
@@ -117,6 +126,11 @@ class PostControllerTest {
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull()
)
).thenReturn(post);
@@ -266,6 +280,11 @@ class PostControllerTest {
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any()
);
}

View File

@@ -26,6 +26,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -52,6 +53,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -104,6 +106,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -130,6 +133,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -195,6 +199,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -221,6 +226,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -260,6 +266,11 @@ class PostServiceTest {
null,
null,
null,
null,
null,
null,
null,
null,
null
)
);
@@ -273,6 +284,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -299,6 +311,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -367,6 +380,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -393,6 +407,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,

View File

@@ -46,3 +46,4 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
# Web push configuration
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
app.snippet-length=${SNIPPET_LENGTH:200}

View File

@@ -25,6 +25,10 @@ services:
timeout: 3s
retries: 30
start_period: 20s
profiles:
- dev
- dev_local_backend
- prod
# OpenSearch Service
opensearch:
@@ -61,6 +65,9 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
@@ -75,6 +82,10 @@ services:
restart: unless-stopped
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
rabbitmq:
image: rabbitmq:3.13-management
@@ -98,6 +109,10 @@ services:
start_period: 30s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
redis:
image: redis:7
@@ -111,6 +126,10 @@ services:
- redis-data:/data
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
springboot:
@@ -155,6 +174,9 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- prod
websocket-service:
image: maven:3.9-eclipse-temurin-17
@@ -186,6 +208,10 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
frontend_dev:
image: node:20
@@ -208,6 +234,28 @@ services:
- openisle-network
profiles:
- dev
frontend_dev_local_backend:
image: node:20
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev-local-backend
working_dir: /app
env_file:
- ${ENV_FILE:-../.env}
command: sh -c "npm install && npm run dev"
volumes:
- ../frontend_nuxt:/app
- frontend-node-modules:/app/node_modules
ports:
- "${FRONTEND_PORT:-3000}:3000"
depends_on:
websocket-service:
condition: service_healthy
networks:
- openisle-network
profiles:
- dev_local_backend
extra_hosts:
- "host.docker.internal:host-gateway"
frontend_service:
build:
@@ -226,13 +274,13 @@ services:
websocket-service:
condition: service_healthy
restart: unless-stopped
profiles: ["staging", "prod"]
profiles:
- prod
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
loopback_8080:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
command:
- -d
- -d
@@ -243,13 +291,37 @@ services:
springboot:
condition: service_healthy
network_mode: "service:frontend_dev"
profiles: ["dev"]
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 启动docker的本机:8080
loopback_8080_host:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080-host
command:
- -d
- -d
- -ly
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
- TCP4:host.docker.internal:8080
network_mode: "service:frontend_dev_local_backend"
depends_on:
frontend_dev_local_backend:
condition: service_started
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev_local_backend
loopback_8082:
image: alpine/socat
@@ -265,13 +337,37 @@ services:
websocket-service:
condition: service_healthy
network_mode: "service:frontend_dev"
profiles: ["dev"]
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev
loopback_8082_host:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082-host
# 监听 127.0.0.1:8082 → 转发到 websocket-service:8082WS 纯 TCP 可直接过)
command:
- -d
- -d
- -ly
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
- TCP4:websocket-service:8082
depends_on:
websocket-service:
condition: service_healthy
network_mode: "service:frontend_dev_local_backend"
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev_local_backend
networks:
openisle-network:

View File

@@ -41,10 +41,13 @@ import GlobalPopups from '~/components/GlobalPopups.vue'
import ConfirmDialog from '~/components/ConfirmDialog.vue'
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
import { useIsMobile } from '~/utils/screen'
import { checkToken } from '~/utils/auth'
const isMobile = useIsMobile()
const menuVisible = ref(!isMobile.value)
await checkToken()
const showNewPostIcon = computed(() => useRoute().path === '/')
const hideMenu = computed(() => {

View File

@@ -3,7 +3,7 @@
--primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5);
--secondary-color: rgb(255, 255, 255);
--secondary-color-hover: rgba(10, 111, 120, 0.184);
--secondary-color-hover: rgba(10, 111, 120, 0.079);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-height: 60px;
--header-background-color: white;
@@ -54,6 +54,7 @@
--header-border-color: #555;
--primary-color: rgb(17, 182, 197);
--primary-color-hover: rgb(13, 137, 151);
--secondary-color-hover: rgba(17, 182, 197, 0.238);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-text-color: white;
--app-menu-background-color: #333;
@@ -179,7 +180,7 @@ body {
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
white-space: nowrap; /* 禁止数字换行 */
white-space: nowrap; /* 禁止数字换行 */
font-variant-numeric: tabular-nums; /* 数字等宽 */
/* width: 2em; */
font-size: 13px;
@@ -205,7 +206,6 @@ body {
border-radius: 4px;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
white-space: pre; /* 禁止自动换行 */
}
.copy-code-btn {
@@ -344,7 +344,7 @@ body {
.info-content-text pre {
line-height: 1.5;
}
/*处理iframe视频标签*/
.info-content-text iframe {
width: 100%;
@@ -370,7 +370,10 @@ body {
.d2h-code-line {
padding-left: 10px !important;
}
/* 手机端不换行 */
.info-content-text code {
white-space: pre; /* 禁止自动换行 */
}
/* .d2h-diff-table {
font-size: 6px !important;
}

View File

@@ -17,7 +17,7 @@ import { computed, ref } from 'vue'
import { useAttrs } from 'vue'
const props = defineProps({
src: { type: String, required: true },
src: { type: String, default: '' },
alt: { type: String, default: '' },
})
@@ -39,9 +39,6 @@ const placeholder = computed(() => {
function onLoad() {
loaded.value = true
}
function onError() {
loaded.value = true
}
</script>
<style scoped>

View File

@@ -0,0 +1,187 @@
<template>
<div
ref="groupRef"
class="base-item-group"
:class="groupClass"
:style="groupStyle"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@focusin="onFocusIn"
@focusout="onFocusOut"
>
<div
v-for="(item, index) in normalizedItems"
:key="resolveKey(item, index)"
class="base-item-group-item"
:style="{ zIndex: getZIndex(index) }"
>
<slot name="item" :item="item" :index="index"></slot>
</div>
<slot name="after"></slot>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
const props = defineProps({
items: {
type: Array,
default: () => [],
},
itemKey: {
type: [String, Function],
default: null,
},
overlap: {
type: [Number, String],
default: 12,
},
expandedGap: {
type: [Number, String],
default: 8,
},
direction: {
type: String,
default: 'horizontal',
validator: (value) => ['horizontal', 'vertical'].includes(value),
},
reverse: {
type: Boolean,
default: false,
},
animationDuration: {
type: [Number, String],
default: 200,
},
})
const groupRef = ref(null)
const state = reactive({
hovering: false,
focused: false,
})
const normalizedItems = computed(() => props.items || [])
const sanitizedOverlap = computed(() => Math.max(0, Number(props.overlap) || 0))
const sanitizedExpandedGap = computed(() => Math.max(0, Number(props.expandedGap) || 0))
const sanitizedAnimationDuration = computed(() => Math.max(0, Number(props.animationDuration) || 0))
const groupClass = computed(() => [
`base-item-group--${props.direction}`,
{
'is-expanded': isExpanded.value,
'is-reversed': props.reverse,
},
])
const groupStyle = computed(() => ({
'--base-item-group-overlap': `${sanitizedOverlap.value}px`,
'--base-item-group-expanded-gap': `${sanitizedExpandedGap.value}px`,
'--base-item-group-transition-duration': `${sanitizedAnimationDuration.value}ms`,
}))
const isExpanded = computed(() => state.hovering || state.focused)
function onMouseEnter() {
state.hovering = true
}
function onMouseLeave() {
state.hovering = false
}
function onFocusIn() {
state.focused = true
}
function onFocusOut(event) {
const nextTarget = event.relatedTarget
if (!groupRef.value) {
state.focused = false
return
}
if (!nextTarget || !groupRef.value.contains(nextTarget)) {
state.focused = false
}
}
function resolveKey(item, index) {
if (typeof props.itemKey === 'function') {
return props.itemKey(item, index)
}
if (props.itemKey && item && Object.prototype.hasOwnProperty.call(item, props.itemKey)) {
return item[props.itemKey]
}
return index
}
function getZIndex(index) {
if (props.reverse) {
return index + 1
}
return normalizedItems.value.length - index
}
</script>
<style scoped>
.base-item-group {
--base-item-group-overlap: 12px;
--base-item-group-expanded-gap: 8px;
--base-item-group-transition-duration: 200ms;
display: inline-flex;
position: relative;
align-items: center;
}
.base-item-group:focus-within {
outline: none;
}
.base-item-group--horizontal {
flex-direction: row;
}
.base-item-group--horizontal.is-reversed {
flex-direction: row-reverse;
}
.base-item-group--vertical {
flex-direction: column;
align-items: flex-start;
}
.base-item-group--vertical.is-reversed {
flex-direction: column-reverse;
}
.base-item-group-item {
transition:
margin var(--base-item-group-transition-duration) ease,
transform var(--base-item-group-transition-duration) ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.base-item-group--horizontal:not(.is-expanded) .base-item-group-item:not(:first-child) {
margin-left: calc(var(--base-item-group-overlap) * -1);
}
.base-item-group--horizontal.is-expanded .base-item-group-item:not(:first-child) {
margin-left: var(--base-item-group-expanded-gap);
}
.base-item-group--vertical:not(.is-expanded) .base-item-group-item:not(:first-child) {
margin-top: calc(var(--base-item-group-overlap) * -1);
}
.base-item-group--vertical.is-expanded .base-item-group-item:not(:first-child) {
margin-top: var(--base-item-group-expanded-gap);
}
.base-item-group.is-expanded .base-item-group-item {
transform: translateZ(0);
}
</style>

View File

@@ -1,22 +1,20 @@
<template>
<NuxtLink
:to="resolvedLink"
<div
class="base-user-avatar"
:class="wrapperClass"
:style="wrapperStyle"
v-bind="wrapperAttrs"
@click="handleClick"
>
<BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
</NuxtLink>
<BaseImage :src="props.src" :alt="altText" class="base-user-avatar-img" />
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, watch } from 'vue'
import { useAttrs } from 'vue'
import BaseImage from './BaseImage.vue'
const DEFAULT_AVATAR = '/default-avatar.svg'
const props = defineProps({
userId: {
type: [String, Number],
@@ -50,15 +48,6 @@ const props = defineProps({
const attrs = useAttrs()
const currentSrc = ref(props.src || DEFAULT_AVATAR)
watch(
() => props.src,
(value) => {
currentSrc.value = value || DEFAULT_AVATAR
},
)
const resolvedLink = computed(() => {
if (props.to) return props.to
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
@@ -70,10 +59,16 @@ const resolvedLink = computed(() => {
const altText = computed(() => props.alt || '用户头像')
const sizeStyle = computed(() => {
if (!props.width && props.width !== 0) return null
const value = typeof props.width === 'number' ? `${props.width}px` : props.width
if (!value) return null
return { width: value, height: value }
var style = {}
if (props.width > 0) {
style.width = `${props.width}px`
}
if (props.height > 0) {
style.height = `${props.height}px`
}
return style
})
const wrapperStyle = computed(() => {
@@ -88,10 +83,9 @@ const wrapperAttrs = computed(() => {
return rest
})
function onError() {
if (currentSrc.value !== DEFAULT_AVATAR) {
currentSrc.value = DEFAULT_AVATAR
}
const handleClick = () => {
if (props.disableLink) return
navigateTo(resolvedLink.value)
}
</script>
@@ -109,7 +103,7 @@ function onError() {
}
.base-user-avatar:hover {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 24px rgba(251, 138, 138, 0.1);
transform: scale(1.05);
}

View File

@@ -488,6 +488,16 @@ const handleContentClick = (e) => {
font-weight: bold;
}
.article-footer-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
margin-top: 0px;
flex-wrap: wrap;
margin-bottom: 0px;
}
.medal-name {
font-size: 12px;
margin-left: 1px;

View File

@@ -0,0 +1,319 @@
<template>
<div class="donate-container">
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
<div class="donate-viewer" @click="openPanel">
<div
class="donate-viewer-item-container"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<BaseItemGroup
:items="donationList"
:overlap="10"
:expanded-gap="2"
:direction="vertical"
>
<template #item="{ item }">
<BaseUserAvatar
:user-id="item.userId"
:src="item.avatar"
:alt="item.username"
:width="20"
:disable-link="true"
/>
</template>
</BaseItemGroup>
<div class="donate-counts-text">{{ totalAmount }}</div>
</div>
</div>
</ToolTip>
<ToolTip content="赞赏作者" placement="bottom" v-else>
<div class="donate-viewer-item placeholder" @click="openPanel">
<financing class="donate-viewer-item-placeholder-icon" />
</div>
</ToolTip>
<div
v-if="panelVisible"
class="donate-panel"
ref="donatePanelRef"
:style="panelInlineStyle"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<div
v-for="option in donateOptions"
:key="option"
class="donate-option"
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
@click="handleDonate(option)"
>
<financing class="donate-option-icon" />
<div class="donate-counts-text">{{ option }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { Finance } from '@icon-park/vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
const financing = Finance
const props = defineProps({
postId: {
type: [Number, String],
required: true,
},
authorId: {
type: [Number, String],
required: true,
},
isAuthor: {
type: Boolean,
default: false,
},
})
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const panelVisible = ref(false)
const donatePanelRef = ref(null)
const panelInlineStyle = ref({})
const donationSummary = ref({ totalAmount: 0, donations: [] })
const donating = ref(false)
let hideTimer = null
const donateOptions = [10, 30, 100]
const donationList = computed(() => donationSummary.value?.donations ?? [])
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
const isAuthorUser = computed(() => {
if (props.isAuthor) return true
if (!authState.userId || !props.authorId) return false
return Number(authState.userId) === Number(props.authorId)
})
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const updatePanelInlineStyle = () => {
if (!panelVisible.value) return
const panelEl = donatePanelRef.value
if (!panelEl) return
const parentEl = panelEl.closest('.donate-container')?.parentElement.parentElement
if (!parentEl) return
const parentWidth = parentEl.clientWidth - 20
panelInlineStyle.value = {
width: 'max-content',
maxWidth: `${parentWidth}px`,
}
}
watch(panelVisible, async (visible) => {
if (visible) {
await nextTick()
updatePanelInlineStyle()
}
})
const normalizeSummary = (data) => ({
totalAmount: data?.totalAmount ?? 0,
donations: Array.isArray(data?.donations) ? data.donations : [],
})
const loadDonations = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
if (!res.ok) return
const data = await res.json()
donationSummary.value = normalizeSummary(data)
} catch (e) {
// ignore network errors for donation summary
}
}
const handleDonate = async (amount) => {
if (!amount || donating.value) return
if (!authState.loggedIn) {
toast.error('请先登录后再打赏')
panelVisible.value = false
return
}
if (isAuthorUser.value) {
toast.warning('不能给自己打赏')
return
}
try {
donating.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token ? `Bearer ${token}` : '',
},
body: JSON.stringify({ amount }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
if (res.status === 401) {
toast.error('请先登录后再打赏')
} else {
toast.error(data?.error || '打赏失败')
}
return
}
donationSummary.value = normalizeSummary(data)
toast.success('打赏成功,感谢你的支持!')
panelVisible.value = false
} catch (e) {
toast.error('打赏失败,请稍后再试')
} finally {
donating.value = false
}
}
onMounted(async () => {
window.addEventListener('resize', updatePanelInlineStyle)
await loadDonations()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePanelInlineStyle)
})
watch(
() => props.postId,
async () => {
donationSummary.value = { totalAmount: 0, donations: [] }
await loadDonations()
},
)
</script>
<style scoped>
.donate-container {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
}
.donate-viewer-item-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.donate-viewer {
border-radius: 13px;
padding: 3px;
padding-right: 6px;
cursor: pointer;
transition: background-color 0.5s ease;
}
.donate-viewer:hover {
background-color: var(--secondary-color-hover);
}
.donate-counts-text {
color: var(--primary-color);
font-size: 14px;
}
.donate-panel {
position: absolute;
bottom: 35px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;
padding: 5px 10px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
z-index: 10;
gap: 5px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
.donate-viewer-item.placeholder {
display: flex;
cursor: pointer;
flex-direction: row;
padding: 2px 10px;
gap: 5px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;
background-color: var(--normal-light-background-color);
}
.donate-viewer-item {
font-size: 16px;
}
.donate-viewer-item-placeholder-icon {
opacity: 0.5;
}
.donate-option {
cursor: pointer;
padding: 3px 6px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.donate-option:hover {
background-color: var(--normal-light-background-color);
}
.donate-option.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.donate-option.disabled:hover {
background-color: transparent;
}
.donate-option-icon {
color: var(--primary-color);
}
@media (max-width: 768px) {
.donate-viewer-item.placeholder {
padding: 4px 8px;
gap: 3px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 3px;
margin-bottom: 3px;
font-size: 12px;
color: var(--text-color);
align-items: center;
}
}
</style>

View File

@@ -168,9 +168,19 @@ export default {
const mobileMenuRef = ref(null)
const isMobile = useIsMobile()
const openMenu = () => {
if (!open.value) {
open.value = true
}
}
const toggle = () => {
open.value = !open.value
if (!open.value) emit('close')
if (open.value) {
open.value = false
emit('close')
} else {
open.value = true
}
}
const close = () => {
@@ -275,7 +285,7 @@ export default {
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
expose({ toggle, close, reload, scrollToBottom })
expose({ toggle, close, reload, scrollToBottom, openMenu })
return {
open,
@@ -308,7 +318,6 @@ export default {
border: 1px solid var(--normal-border-color);
border-radius: 5px;
padding: 5px 10px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
@@ -331,6 +340,7 @@ export default {
z-index: 10000;
max-height: 300px;
min-width: 350px;
margin-top: 4px;
overflow-y: auto;
}

View File

@@ -26,6 +26,11 @@
<ClientOnly>
<div class="header-content-right">
<SearchDropdown
ref="searchDropdown"
v-if="!isMobile || showSearch"
@close="closeSearch"
/>
<!-- 搜索 -->
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
<div class="header-icon-item" @click="search">
@@ -78,7 +83,9 @@
<div class="header-icon-item" @click="goToMessages">
<message-emoji class="header-icon" />
<span class="header-label">消息</span>
<span v-if="unreadMessageCount > 0" class="unread-badge">{{ unreadMessageCount }}</span>
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount
}}</span>
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
</div>
</ToolTip>
@@ -89,10 +96,9 @@
<BaseUserAvatar
class="avatar-img"
:user-id="authState.userId"
:src="avatar"
alt="avatar"
:width="32"
:src="authState.avatar"
:disable-link="true"
:width="32"
/>
<down />
</div>
@@ -105,7 +111,6 @@
</div>
</div>
</ClientOnly>
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
</div>
</header>
</template>
@@ -117,7 +122,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { authState, clearToken } from '~/utils/auth'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen'
@@ -139,13 +144,11 @@ const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const isCopying = ref(false)
const onlineCount = ref(0)
// 心跳检测
@@ -208,7 +211,7 @@ const copyInviteLink = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
isCopying.value = false // 🔥 修复:未登录时立即复原状态
isCopying.value = false // 🔥 修复:未登录时立即复原状态
return
}
try {
@@ -252,17 +255,7 @@ const copyRssLink = async () => {
}
const goToProfile = async () => {
if (!authState.loggedIn) {
navigateTo('/login', { replace: true })
return
}
let id = authState.username || authState.userId
if (!id) {
const user = await loadCurrentUser()
if (user) {
id = user.username || user.id
}
}
let id = authState.username || authState.id
if (id) {
navigateTo(`/users/${id}`, { replace: true })
}
@@ -306,14 +299,6 @@ const iconClass = computed(() => {
})
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
const user = await loadCurrentUser()
if (user && user.avatar) {
avatar.value = user.avatar
}
}
}
const updateUnread = async () => {
if (authState.loggedIn) {
fetchUnreadCount()
@@ -323,17 +308,8 @@ onMounted(async () => {
}
}
await updateAvatar()
await updateUnread()
watch(
() => authState.loggedIn,
async (isLoggedIn) => {
await updateAvatar()
await updateUnread()
},
)
// 新增的在线人数逻辑
sendPing()
fetchCount()
@@ -482,7 +458,6 @@ onMounted(async () => {
cursor: pointer;
}
.invite_text:hover {
opacity: 0.8;
text-decoration: underline;
@@ -543,7 +518,10 @@ onMounted(async () => {
color: var(--primary-color);
cursor: pointer;
position: relative;
transition: color 0.25s ease, transform 0.15s ease, opacity 0.2s ease;
transition:
color 0.25s ease,
transform 0.15s ease,
opacity 0.2s ease;
}
.header-icon-item:hover {
@@ -572,15 +550,14 @@ onMounted(async () => {
position: absolute;
top: -4px;
right: -6px;
color: var(--primary-color); /* 🔹 使用主题主色 */
background: none; /* 🔹 去掉背景 */
font-size: 11px; /* 字体稍微大一点以便清晰 */
font-weight: 600; /* 加一点权重让数字更醒目 */
color: var(--primary-color); /* 🔹 使用主题主色 */
background: none; /* 🔹 去掉背景 */
font-size: 11px; /* 字体稍微大一点以便清晰 */
font-weight: 600; /* 加一点权重让数字更醒目 */
line-height: 1;
padding: 0; /* 去掉内边距 */
padding: 0; /* 去掉内边距 */
}
@keyframes rss-glow {
0% {
text-shadow: 0 0 0px var(--primary-color);

View File

@@ -45,6 +45,7 @@ export default {
font-size: 12px;
cursor: pointer;
margin-left: 10px;
white-space: nowrap;
}
.mark-read-button:hover {
@@ -53,6 +54,7 @@ export default {
.has-read-button {
font-size: 12px;
white-space: nowrap;
}
@media (max-width: 768px) {

View File

@@ -4,11 +4,7 @@
<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>
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
</div>
<div class="add-option" @click="addOption">添加选项</div>
</div>

View File

@@ -42,6 +42,9 @@
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
>系统已精密计算抽奖结果 (=゚ω゚)</span
>
<span v-else-if="log.type === 'DONATE'" class="change-log-content"
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
>
</div>
<div class="change-log-time">{{ log.time }}</div>
<div

View File

@@ -2,6 +2,30 @@
<div class="post-poll-container" v-if="poll">
<div class="poll-top-container">
<div class="poll-options-container">
<div class="poll-title-section">
<div class="poll-title-section-row">
<div class="poll-option-title" v-if="poll.multiple">多选</div>
<div class="poll-option-title" v-else-if="isProposal">
拟议分类{{ poll.proposedName }}
<ToolTip
content="🗳️ 提案提交后将开放3天投票需达到至少60%的赞成率并满10人参与方可通过。"
placement="bottom"
v-if="isProposal"
>
<info-icon class="info-icon" />
</ToolTip>
</div>
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
<div class="poll-title-section-row">
<div v-if="poll.description" class="proposal-description">{{ poll.description }}</div>
</div>
</div>
<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">
@@ -29,16 +53,6 @@
</div>
</div>
<div v-else>
<div class="poll-title-section">
<div class="poll-option-title" v-if="poll.multiple">多选</div>
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
<template v-if="poll.multiple">
<div
v-for="(opt, idx) in poll.options"
@@ -103,11 +117,6 @@
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
<div v-else class="poll-option-hint">
<div>您已投票等待结束查看结果</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
</div>
</div>
@@ -130,6 +139,9 @@ const emit = defineEmits(['refresh'])
const loggedIn = computed(() => authState.loggedIn)
const showPollResult = ref(false)
const isProposal = computed(() =>
Object.prototype.hasOwnProperty.call(props.poll || {}, 'proposedName'),
)
const pollParticipants = computed(() => props.poll?.participants || [])
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
const pollVotes = computed(() => props.poll?.votes || {})
@@ -233,6 +245,34 @@ const submitMultiPoll = async () => {
padding: 10px;
}
.proposal-info {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
background-color: var(--background-color);
color: var(--text-color);
}
.proposal-name {
font-weight: 600;
font-size: 16px;
}
.proposal-status {
font-size: 14px;
opacity: 0.8;
}
.proposal-description {
font-size: 16px;
margin-top: 10px;
line-height: 1.5;
white-space: pre-wrap;
opacity: 0.8;
}
.poll-option-button {
color: var(--text-color);
padding: 5px 10px;
@@ -385,12 +425,20 @@ const submitMultiPoll = async () => {
}
.poll-title-section {
display: flex;
gap: 30px;
flex-direction: row;
margin-bottom: 20px;
}
.poll-title-section-row {
display: flex;
align-items: center;
flex-direction: row;
gap: 30px;
}
.info-icon {
margin-right: 20px;
}
.poll-option-title {
font-size: 18px;
font-weight: bold;

View File

@@ -34,6 +34,7 @@ export default {
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
{ id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' },
]
}

View File

@@ -0,0 +1,77 @@
<template>
<div class="proposal-section">
<div class="proposal-row">
<span class="proposal-row-title rule">
<info-icon class="proposal-description-title-icon" />提案规则说明</span
>
<div class="proposal-description-content">
<p>📛 拟议分类名称需保持唯一请勿与现有分类或正在提案中的名称重复</p>
<p>📝 请在下方详细说明提案目的预期价值及补充材料方便大家快速理解</p>
<p>🗳 提案提交后将开放 3 天投票需达到至少 60% 的赞成率并满 10 人参与方可通过</p>
<p>🤝 讨论请遵循社区守则保持礼貌和善欢迎附上相关案例或参考链接</p>
</div>
</div>
<div class="proposal-row">
<span class="proposal-row-title">拟议分类名称</span>
<BaseInput v-model="data.proposedName" placeholder="请输入分类名称" />
</div>
<div class="proposal-row">
<span class="proposal-row-title">提案描述</span>
<BaseInput v-model="data.proposalDescription" placeholder="简要说明提案目的与理由" />
</div>
</div>
</template>
<script setup>
import BaseInput from '~/components/BaseInput.vue'
defineProps({
data: { type: Object, required: true },
})
</script>
<style scoped>
.proposal-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.proposal-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.proposal-row-title.rule {
margin-bottom: 0;
}
.proposal-row {
display: flex;
flex-direction: column;
}
.proposal-activity {
margin-top: 20px;
padding: 20px;
}
.proposal-description-title-text {
font-size: 14px;
font-weight: bold;
margin-left: 5px;
}
.proposal-description-title-icon {
margin-right: 5px;
}
.proposal-description-content {
font-size: 12px;
opacity: 0.8;
}
</style>

View File

@@ -18,9 +18,11 @@
<div>{{ counts[r.type] }}</div>
</div>
<div class="reactions-viewer-item placeholder" @click="openPanel">
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
</div>
<ToolTip content="发表心情" placement="bottom">
<div class="reactions-viewer-item placeholder" @click="openPanel">
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
</div>
</ToolTip>
</template>
<template v-else-if="displayedReactions.length">
<div
@@ -164,7 +166,7 @@ const updatePanelInlineStyle = () => {
if (!panelVisible.value) return
const panelEl = reactionsPanelRef.value
if (!panelEl) return
const parentEl = panelEl.closest('.reactions-container')?.parentElement
const parentEl = panelEl.closest('.reactions-container')?.parentElement?.parentElement
if (!parentEl) return
const parentWidth = parentEl.clientWidth - 20
panelInlineStyle.value = {
@@ -320,11 +322,12 @@ onBeforeUnmount(() => {
.reactions-count {
font-size: 16px;
font-weight: bold;
margin-right: 15px;
}
.reactions-panel {
position: absolute;
bottom: 40px;
bottom: 35px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;
@@ -357,7 +360,6 @@ onBeforeUnmount(() => {
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;

View File

@@ -17,7 +17,8 @@
<input
class="text-input"
v-model="keyword"
placeholder="Search"
placeholder="键盘点击「/」以触发搜索"
ref="searchInput"
@input="setSearch(keyword)"
/>
</div>
@@ -48,7 +49,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
@@ -61,8 +62,48 @@ const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const searchInput = ref(null)
const isMobile = useIsMobile()
const isEditableElement = (el) => {
if (!el) return false
if (el.isContentEditable) return true
const tagName = el.tagName ? el.tagName.toLowerCase() : ''
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true
}
const role = el.getAttribute ? el.getAttribute('role') : null
return role === 'textbox'
}
const focusSearchInput = () => {
if (!searchInput.value) return
dropdown.value?.openMenu?.()
if (typeof searchInput.value.focus === 'function') {
try {
searchInput.value.focus({ preventScroll: true })
} catch (e) {
searchInput.value.focus()
}
}
}
const handleGlobalSlash = (event) => {
if (event.defaultPrevented) return
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return
if (isEditableElement(document.activeElement)) return
event.preventDefault()
focusSearchInput()
}
onMounted(() => {
window.addEventListener('keydown', handleGlobalSlash)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleGlobalSlash)
})
const toggle = () => {
dropdown.value.toggle()
}
@@ -144,8 +185,7 @@ defineExpose({
<style scoped>
.search-dropdown {
margin-top: 20px;
width: 500px;
width: 300px;
}
.search-mobile-trigger {
@@ -154,7 +194,7 @@ defineExpose({
}
.search-input {
padding: 10px;
padding: 2px 10px;
display: flex;
align-items: center;
width: 100%;
@@ -202,7 +242,7 @@ defineExpose({
}
.result-body {
line-height: 1;
line-height: 1;
display: flex;
flex-direction: column;
}
@@ -216,4 +256,14 @@ defineExpose({
font-size: 12px;
color: #666;
}
.search-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10000;
}
</style>

View File

@@ -1,10 +1,5 @@
<template>
<div class="home-page">
<div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航在此遇见灵感与共鸣</div>
<SearchDropdown />
</div>
<div class="topic-container">
<div class="topic-item-container">
<div
@@ -72,11 +67,12 @@
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
<star v-if="!article.rssExcluded" class="featured-icon" />
{{ article.title }}
</NuxtLink>
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
<div v-html="sanitizeDescription(article.description)"></div>
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
</NuxtLink>
<div class="article-info-container main-item">
<ArticleCategory :category="article.category" />
@@ -116,7 +112,7 @@
</div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<InfiniteLoadMore
v-if="articles.length > 0"
:key="ioKey"
@@ -143,6 +139,7 @@ import { useIsMobile } from '~/utils/screen'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import TimeManager from '~/utils/time'
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
useHead({
title: 'OpenIsle - 全面开源的自由社区',
meta: [
@@ -378,28 +375,6 @@ onBeforeUnmount(() => {
/** 供 InfiniteLoadMore 重建用的 key筛选/Tab 改变即重建内部状态 */
const ioKey = computed(() => asyncKey.value.join('::'))
// 在首页摘要加载贴吧表情包
const sanitizeDescription = (text) => {
if (!text) return ''
// 1⃣ 先把 Markdown 转成纯文本
const plain = stripMarkdown(text)
// 2⃣ 替换 :tieba123: 为 <img>
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
const key = `tieba${num}`
const file = tiebaEmoji[key]
return file
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
: match // 没有匹配到图片则保留原样
})
// 3 可选截断纯文本长度防止撑太长
const truncated = withEmoji.length > 500 ? withEmoji.slice(0, 500) + '…' : withEmoji
return truncated
}
// 页面选项同步到全局状态
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
selectedCategoryGlobal.value = newCategory
@@ -564,14 +539,14 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.header-item.views {
width: 5%;
justify-content: flex-end;
text-align: right;
text-align: right;
}
.article-time,
.header-item.activity {
width: 10%;
justify-content: flex-end;
text-align: left;
text-align: left;
}
.article-item-title {
@@ -593,6 +568,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.pinned-icon,
.lottery-icon,
.featured-icon,
.proposal-icon,
.poll-icon {
margin-right: 4px;
color: var(--primary-color);

View File

@@ -40,7 +40,7 @@
<script setup>
import { toast } from '~/main'
import { setToken, loadCurrentUser } from '~/utils/auth'
import { setToken } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue'
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
import { registerPush } from '~/utils/push'
@@ -61,7 +61,6 @@ const submitLogin = async () => {
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
await navigateTo('/', { replace: true })

View File

@@ -84,7 +84,7 @@
>
<div class="conversation-avatar">
<BaseImage
:src="ch.avatar || '/default-avatar.svg'"
:src="ch.avatar"
:alt="ch.name"
class="avatar-img"
@error="handleAvatarError"
@@ -194,7 +194,7 @@ function formatTime(timeString) {
// 头像加载失败处理
function handleAvatarError(event) {
event.target.src = '/default-avatar.svg'
event.target.src = null
}
async function fetchChannels() {

View File

@@ -75,7 +75,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
{{ stripMarkdownLength(item.parentComment.content, 100) }}
<span
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
></span>
</NuxtLink>
</span>
回复了
@@ -85,7 +87,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
></span>
</NuxtLink>
</span>
</NotificationContainer>
@@ -115,7 +119,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
></span>
</NuxtLink>
</span>
</NotificationContainer>
@@ -162,7 +168,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
></span>
</NuxtLink>
</span>
进行了表态
@@ -251,6 +259,38 @@
已出结果
</NotificationContainer>
</template>
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_OWNER'">
<NotificationContainer :item="item" :markRead="markRead">
你的分类提案
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
<span v-if="item.approved">已通过</span>
<span v-else>
未通过<span v-if="item.content">原因{{ item.content }}</span>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'">
<NotificationContainer :item="item" :markRead="markRead">
你参与的分类提案
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
<span v-if="item.approved">已通过</span>
<span v-else>
未通过<span v-if="item.content">原因{{ item.content }}</span>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
@@ -267,7 +307,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -287,7 +327,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
{{ stripMarkdownLength(item.parentComment.content, 100) }}
<span
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
></span>
</NuxtLink>
回复了
<NuxtLink
@@ -295,7 +337,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -323,7 +365,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -342,7 +384,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -542,6 +584,27 @@
被收录为精选
</NotificationContainer>
</template>
<template v-else-if="item.type === 'DONATION'">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
在帖子
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
打赏了你
<template v-if="item.content"> 获得 {{ item.content }} 积分 </template>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_DELETED'">
<NotificationContainer :item="item" :markRead="markRead">
管理员
@@ -556,7 +619,7 @@
</template>
删除了您的帖子
<span class="notif-content-text">
{{ stripMarkdownLength(item.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.content, 500)"></span>
</span>
</NotificationContainer>
</template>
@@ -586,7 +649,7 @@ import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
import BaseTabs from '~/components/BaseTabs.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { stripMarkdownLength } from '~/utils/markdown'
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
import {
fetchNotifications,
fetchUnreadCount,
@@ -754,6 +817,10 @@ const formatType = (t) => {
return '发布的投票结果已公布'
case 'POLL_RESULT_PARTICIPANT':
return '参与的投票结果已公布'
case 'CATEGORY_PROPOSAL_RESULT_OWNER':
return '分类提案结果已公布'
case 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT':
return '参与的分类提案结果已公布'
default:
return t
}

View File

@@ -37,6 +37,7 @@
</div>
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
<PollForm v-if="postType === 'POLL'" :data="poll" />
<ProposalForm v-if="postType === 'PROPOSAL'" :data="proposal" />
</div>
</div>
</template>
@@ -50,6 +51,7 @@ 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 ProposalForm from '~/components/ProposalForm.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
@@ -76,6 +78,10 @@ const poll = reactive({
endTime: null,
multiple: false,
})
const proposal = reactive({
proposedName: '',
proposalDescription: '',
})
const startTime = ref(null)
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
@@ -123,6 +129,8 @@ const clearPost = async () => {
poll.options = ['', '']
poll.endTime = null
poll.multiple = false
proposal.proposedName = ''
proposal.proposalDescription = ''
// 删除草稿
const token = getToken()
@@ -283,6 +291,12 @@ const submitPost = async () => {
return
}
}
if (postType.value === 'PROPOSAL') {
if (!proposal.proposedName.trim()) {
toast.error('请填写拟议分类名称')
return
}
}
try {
const token = getToken()
await ensureTags(token)
@@ -303,35 +317,43 @@ const submitPost = async () => {
}
prizeIconUrl = uploadData.data.url
}
const toUtcString = (value) => {
if (!value) return undefined
return new Date(new Date(value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
}
const payload = {
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
}
if (postType.value === 'LOTTERY') {
payload.prizeIcon = prizeIconUrl
payload.prizeName = lottery.prizeName
payload.prizeCount = lottery.prizeCount
payload.prizeDescription = lottery.prizeDescription
payload.pointCost = lottery.pointCost
payload.startTime = startTime.value ? new Date(startTime.value).toISOString() : undefined
payload.endTime = toUtcString(lottery.endTime)
} else if (postType.value === 'POLL') {
payload.options = poll.options
payload.multiple = poll.multiple
payload.endTime = toUtcString(poll.endTime)
} else if (postType.value === 'PROPOSAL') {
payload.proposedName = proposal.proposedName
payload.proposalDescription = proposal.proposalDescription
}
const res = await fetch(`${API_BASE_URL}/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : 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(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,
}),
body: JSON.stringify(payload),
})
const data = await res.json()
if (res.ok) {

View File

@@ -184,6 +184,27 @@
}}</NuxtLink>
参与获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'DONATE_SENT'">
你在文章
<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 === 'DONATE_RECEIVED'">
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
在文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
中打赏了你获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
<paper-money-two /> 你目前的积分是 {{ item.balance }}
</div>
@@ -248,6 +269,8 @@ const iconMap = {
FEATURE: 'star',
LOTTERY_JOIN: 'medal-one',
LOTTERY_REWARD: 'fireworks',
DONATE_SENT: 'paper-money-two',
DONATE_RECEIVED: 'paper-money-two',
POST_LIKE_CANCELLED: 'clear-icon',
COMMENT_LIKE_CANCELLED: 'clear-icon',
}

View File

@@ -92,12 +92,15 @@
></div>
<div class="article-footer-container">
<ReactionsGroup
ref="postReactionsGroupRef"
v-model="postReactions"
content-type="post"
:content-id="postId"
/>
<div class="article-option-container">
<ReactionsGroup
ref="postReactionsGroupRef"
v-model="postReactions"
content-type="post"
:content-id="postId"
/>
<DonateGroup :post-id="postId" :author-id="author.id" :is-author="isAuthor" />
</div>
<div class="article-footer-actions">
<div
class="reaction-action like-action"
@@ -211,6 +214,7 @@ import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DonateGroup from '~/components/DonateGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import PostLottery from '~/components/PostLottery.vue'
import PostPoll from '~/components/PostPoll.vue'
@@ -408,6 +412,8 @@ const changeLogIcon = (l) => {
return 'check-one'
} else if (l.type === 'LOTTERY_RESULT') {
return 'gift'
} else if (l.type === 'DONATE') {
return 'financing'
} else {
return 'info'
}
@@ -432,6 +438,7 @@ const mapChangeLog = (l) => ({
newCategory: l.newCategory,
oldTags: l.oldTags,
newTags: l.newTags,
amount: l.amount,
icon: changeLogIcon(l),
})
@@ -1276,6 +1283,14 @@ onMounted(async () => {
gap: 10px;
margin-top: 0px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.article-option-container {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
.article-footer-actions {
@@ -1367,6 +1382,7 @@ onMounted(async () => {
.article-footer-container {
margin-top: 0;
margin-bottom: 0px;
}
.loading-container {

View File

@@ -70,7 +70,7 @@
<script setup>
import BaseInput from '~/components/BaseInput.vue'
import { toast } from '~/main'
import { loadCurrentUser, setToken } from '~/utils/auth'
import { setToken } from '~/utils/auth'
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
const route = useRoute()
@@ -172,7 +172,6 @@ const verifyCode = async () => {
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
toast.success('注册成功')
setToken(data.token)
loadCurrentUser()
navigateTo('/', { replace: true })
} else if (data.reason_code === 'VERIFIED') {
if (registerMode.value === 'WHITELIST') {

View File

@@ -80,6 +80,8 @@ import {
Dislike,
CheckOne,
Share,
Financing,
Hands,
} from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => {
@@ -163,4 +165,6 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('Dislike', Dislike)
nuxtApp.vueApp.component('CheckOne', CheckOne)
nuxtApp.vueApp.component('Share', Share)
nuxtApp.vueApp.component('Financing', Financing)
nuxtApp.vueApp.component('Hands', Hands)
})

View File

@@ -1 +0,0 @@
<svg t="1755789348718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13787" width="400" height="400"><path d="M152.773168 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288198 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288198 56.288199h-45.030559c-37.525466 0-56.281839-18.762733-56.281839-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13788"></path><path d="M409.294708 763.229814h228.968944v146.285714c0 63.22723-51.263602 114.484472-114.484472 114.484472-63.23359 0-114.484472-51.257242-114.484472-114.484472v-146.285714z" fill="#C5AC95" p-id="13789"></path><path d="M73.97605 520.357366c0 55.957466 45.361292 101.318758 101.318757 101.318758 55.951106 0 101.312398-45.361292 101.312398-101.318758 0-55.951106-45.361292-101.312398-101.318758-101.312397-55.951106 0-101.312398 45.361292-101.312397 101.318758z" fill="#C9AB90" p-id="13790"></path><path d="M490.48964 2.531379c186.520646 0 337.710112 151.195826 337.710112 337.716472v382.740671c0 99.474286-80.63523 180.109516-180.109516 180.109515H287.858484c-74.599354 0-135.078957-60.485963-135.078956-135.085317V340.247851C152.773168 153.727205 303.968994 2.531379 490.48964 2.531379z" fill="#EBD3BD" p-id="13791"></path><path d="M400.434882 509.099727c124.342857 0 225.140075 93.241242 225.140075 208.259975 0 5.679702-0.25441 11.308522-0.731429 16.880099H176.019876a195.278708 195.278708 0 0 1-0.731429-16.880099c0-115.018733 100.797217-208.259975 225.146435-208.259975zM805.684472 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288199 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288199 56.288199h-45.030559c-37.525466 0-56.288199-18.762733-56.288199-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13792"></path><path d="M749.402634 520.357366c0 55.957466 45.361292 101.318758 101.312397 101.318758s101.318758-45.361292 101.318758-101.318758c0-55.951106-45.367652-101.312398-101.318758-101.312397s-101.318758 45.361292-101.318758 101.318758z" fill="#EBD3BD" p-id="13793"></path><path d="M805.684472 509.099727a45.030559 45.030559 0 1 0 90.061118 0.01908 45.030559 45.030559 0 0 0-90.061118-0.01908z" fill="#E89E80" p-id="13794"></path><path d="M175.288447 374.01441a90.061118 90.061118 0 1 0 180.115876 0c0-49.737143-40.323975-90.054758-90.061118-90.054758s-90.054758 40.323975-90.054758 90.061118z" fill="#FFFFFF" p-id="13795"></path><path d="M220.319006 379.64323a39.401739 39.401739 0 1 0 78.803478 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13796"></path><path d="M490.48964 374.01441c0 49.737143 40.323975 90.061118 90.061118 90.061118s90.048398-40.323975 90.048397-90.061118-40.317615-90.054758-90.054757-90.054758-90.061118 40.323975-90.061118 90.061118z" fill="#FFFFFF" p-id="13797"></path><path d="M535.520199 379.64323a39.401739 39.401739 0 1 0 78.797118 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13798"></path><path d="M394.806062 362.75677a40.18405 40.18405 0 0 1 37.754435 26.458634l41.99036 115.47031A78.803478 78.803478 0 0 1 400.504845 610.412124h-17.789615a78.803478 78.803478 0 0 1-72.920249-108.633043l46.207205-112.970733a41.920398 41.920398 0 0 1 38.797516-26.051578z" fill="#E89E80" p-id="13799"></path><path d="M165.36646 190.807453m38.16149 0l101.763975 0q38.161491 0 38.161491 38.161491l0 0q0 38.161491-38.161491 38.161491l-101.763975 0q-38.161491 0-38.16149-38.161491l0 0q0-38.161491 38.16149-38.161491Z" fill="#4D4132" p-id="13800"></path><path d="M483.378882 190.807453m38.161491 0l127.204969 0q38.161491 0 38.16149 38.161491l0 0q0 38.161491-38.16149 38.161491l-127.204969 0q-38.161491 0-38.161491-38.161491l0 0q0-38.161491 38.161491-38.161491Z" fill="#4D4132" p-id="13801"></path></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,33 +1,28 @@
import { reactive } from 'vue'
const TOKEN_KEY = 'token'
const USER_ID_KEY = 'userId'
const USERNAME_KEY = 'username'
const ROLE_KEY = 'role'
export const authState = reactive({
loggedIn: false,
userId: null,
username: null,
role: null,
avatar: null,
})
if (import.meta.client) {
authState.loggedIn =
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
authState.userId = localStorage.getItem(USER_ID_KEY)
authState.username = localStorage.getItem(USERNAME_KEY)
authState.role = localStorage.getItem(ROLE_KEY)
}
export function getToken() {
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
}
export function setToken(token) {
export async function setToken(token) {
if (import.meta.client) {
localStorage.setItem(TOKEN_KEY, token)
authState.loggedIn = true
await loadCurrentUser()
}
}
@@ -39,26 +34,20 @@ export function clearToken() {
}
}
export function setUserInfo({ id, username }) {
export function setUserInfo(user) {
if (import.meta.client) {
authState.userId = id
authState.username = username
if (arguments[0] && arguments[0].role) {
authState.role = arguments[0].role
localStorage.setItem(ROLE_KEY, arguments[0].role)
}
if (id !== undefined && id !== null) localStorage.setItem(USER_ID_KEY, id)
if (username) localStorage.setItem(USERNAME_KEY, username)
authState.userId = user.id
authState.username = user.username
authState.avatar = user.avatar
authState.role = user.role
}
}
export function clearUserInfo() {
if (import.meta.client) {
localStorage.removeItem(USER_ID_KEY)
localStorage.removeItem(USERNAME_KEY)
localStorage.removeItem(ROLE_KEY)
authState.userId = null
authState.username = null
authState.avatar = null
authState.role = null
}
}
@@ -82,9 +71,11 @@ export async function fetchCurrentUser() {
export async function loadCurrentUser() {
const user = await fetchCurrentUser()
if (user) {
setUserInfo({ id: user.id, username: user.username, role: user.role })
setUserInfo(user)
} else {
clearUserInfo()
}
return user
authState.loggedIn = user !== null
}
export function isLogin() {
@@ -100,10 +91,12 @@ export async function checkToken() {
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
headers: { Authorization: `Bearer ${token}` },
})
authState.loggedIn = res.ok
return res.ok
if (res.ok) {
await setToken(token)
} else {
clearToken()
}
} catch (e) {
authState.loggedIn = false
return false
clearToken()
}
}

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
export function discordAuthorize(inviteToken = '') {
@@ -47,7 +47,6 @@ export async function discordExchange(code, inviteToken = '', reason = '') {
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
export function githubAuthorize(inviteToken = '') {
@@ -45,7 +45,6 @@ export async function githubExchange(code, inviteToken = '', reason = '') {
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
export async function googleGetIdToken() {
@@ -79,7 +79,6 @@ export async function googleAuthWithToken(
if (res.ok && data && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
if (typeof redirect_success === 'function') redirect_success()

View File

@@ -265,3 +265,26 @@ export function stripMarkdownLength(text, length) {
}
return plain.slice(0, length) + '...'
}
// 朴素文本带贴吧表情
export function stripMarkdownWithTiebaMoji(text, length){
console.error(text)
if (!text) return ''
// Markdown 转成纯文本
const plain = stripMarkdown(text)
console.error(plain)
// 替换 :tieba123: 为 <img>
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
const key = `tieba${num}`
const file = tiebaEmoji[key]
return file
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
: match // 没有匹配到图片则保留原样
})
// 截断纯文本长度(防止撑太长)
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
return truncated
}

View File

@@ -28,9 +28,12 @@ const iconMap = {
POLL_VOTE: 'ChartHistogram',
POLL_RESULT_OWNER: 'RankingList',
POLL_RESULT_PARTICIPANT: 'ChartLine',
CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne',
CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne',
MENTION: 'HashtagKey',
POST_DELETED: 'ClearIcon',
POST_FEATURED: 'Star',
DONATION: 'PaperMoneyTwo',
}
export async function fetchUnreadCount() {
@@ -253,7 +256,9 @@ function createFetchNotifications() {
} else if (
n.type === 'POLL_VOTE' ||
n.type === 'POLL_RESULT_OWNER' ||
n.type === 'POLL_RESULT_PARTICIPANT'
n.type === 'POLL_RESULT_PARTICIPANT' ||
n.type === 'CATEGORY_PROPOSAL_RESULT_OWNER' ||
n.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'
) {
arr.push({
...n,
@@ -334,6 +339,18 @@ function createFetchNotifications() {
}
},
})
} else if (n.type === 'DONATION') {
arr.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
arr.push({
...n,

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
export function telegramAuthorize(inviteToken = '') {
@@ -34,7 +34,6 @@ export async function telegramExchange(authData, inviteToken = '', reason = '')
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
function generateCodeVerifier() {
@@ -99,7 +99,6 @@ export async function twitterExchange(code, state, reason) {
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
return { success: true, needReason: false }