mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-20 05:50:53 +08:00
Compare commits
16 Commits
codex/crea
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17b644a9b | ||
|
|
61f8fa4bb7 | ||
|
|
43929bcdc5 | ||
|
|
6aecb4f583 | ||
|
|
0d2e6a9505 | ||
|
|
b2d70b9bde | ||
|
|
d914579d64 | ||
|
|
8643446d8b | ||
|
|
2db958f8c9 | ||
|
|
fa29d255c9 | ||
|
|
b3fa5e2bef | ||
|
|
a7ef4380d8 | ||
|
|
39d954d98a | ||
|
|
596d1558a2 | ||
|
|
ce04570efb | ||
|
|
215c7077d5 |
@@ -53,8 +53,8 @@ cd OpenIsle
|
|||||||
--profile dev up -d
|
--profile dev up -d
|
||||||
```
|
```
|
||||||
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
||||||
|
修改前端代码,页面会热更新。
|
||||||
修改代码后,可以强制重新创建所有容器,执行:
|
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA,采用IDEA编译运行也可以哦。
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose \
|
docker compose \
|
||||||
@@ -73,6 +73,12 @@ cd OpenIsle
|
|||||||
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 down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行:
|
||||||
|
```shell
|
||||||
|
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v
|
||||||
|
```
|
||||||
|
`-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。
|
||||||
|
|
||||||
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
||||||
|
|
||||||
## 启动后端服务
|
## 启动后端服务
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal 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;
|
||||||
|
}
|
||||||
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class DonationRequest {
|
||||||
|
|
||||||
|
private int amount;
|
||||||
|
}
|
||||||
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -29,4 +29,5 @@ public class PostChangeLogDto {
|
|||||||
private LocalDateTime newPinnedAt;
|
private LocalDateTime newPinnedAt;
|
||||||
private Boolean oldFeatured;
|
private Boolean oldFeatured;
|
||||||
private Boolean newFeatured;
|
private Boolean newFeatured;
|
||||||
|
private Integer amount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ public class PostChangeLogMapper {
|
|||||||
} else if (log instanceof PostFeaturedChangeLog f) {
|
} else if (log instanceof PostFeaturedChangeLog f) {
|
||||||
dto.setOldFeatured(f.isOldFeatured());
|
dto.setOldFeatured(f.isOldFeatured());
|
||||||
dto.setNewFeatured(f.isNewFeatured());
|
dto.setNewFeatured(f.isNewFeatured());
|
||||||
|
} else if (log instanceof PostDonateChangeLog d) {
|
||||||
|
dto.setAmount(d.getAmount());
|
||||||
}
|
}
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ public enum NotificationType {
|
|||||||
POLL_RESULT_PARTICIPANT,
|
POLL_RESULT_PARTICIPANT,
|
||||||
/** Your post was featured */
|
/** Your post was featured */
|
||||||
POST_FEATURED,
|
POST_FEATURED,
|
||||||
|
/** Someone donated to your post */
|
||||||
|
DONATION,
|
||||||
/** You were mentioned in a post or comment */
|
/** You were mentioned in a post or comment */
|
||||||
MENTION,
|
MENTION,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ public enum PointHistoryType {
|
|||||||
REDEEM,
|
REDEEM,
|
||||||
LOTTERY_JOIN,
|
LOTTERY_JOIN,
|
||||||
LOTTERY_REWARD,
|
LOTTERY_REWARD,
|
||||||
|
DONATE_SENT,
|
||||||
|
DONATE_RECEIVED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ public enum PostChangeType {
|
|||||||
FEATURED,
|
FEATURED,
|
||||||
VOTE_RESULT,
|
VOTE_RESULT,
|
||||||
LOTTERY_RESULT,
|
LOTTERY_RESULT,
|
||||||
|
DONATE,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ package com.openisle.repository;
|
|||||||
|
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.PointHistory;
|
import com.openisle.model.PointHistory;
|
||||||
|
import com.openisle.model.PointHistoryType;
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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> {
|
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
||||||
List<PointHistory> findByUserOrderByIdDesc(User user);
|
List<PointHistory> findByUserOrderByIdDesc(User user);
|
||||||
@@ -21,4 +24,11 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
|
|||||||
List<PointHistory> findByComment(Comment comment);
|
List<PointHistory> findByComment(Comment comment);
|
||||||
|
|
||||||
List<PointHistory> findByPost(Post post);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.dto.DonationDto;
|
||||||
|
import com.openisle.dto.DonationResponse;
|
||||||
import com.openisle.exception.FieldException;
|
import com.openisle.exception.FieldException;
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
import com.openisle.repository.*;
|
import com.openisle.repository.*;
|
||||||
@@ -8,8 +10,10 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -20,6 +24,8 @@ public class PointService {
|
|||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final PointHistoryRepository pointHistoryRepository;
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final PostChangeLogService postChangeLogService;
|
||||||
|
|
||||||
public int awardForPost(String userName, Long postId) {
|
public int awardForPost(String userName, Long postId) {
|
||||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
@@ -272,4 +278,95 @@ public class PointService {
|
|||||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
return recalculateUserPoints(user);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ public class PostChangeLogService {
|
|||||||
logRepository.save(log);
|
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) {
|
public void deleteLogsForPost(Post post) {
|
||||||
logRepository.deleteByPost(post);
|
logRepository.deleteByPost(post);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,13 @@ import GlobalPopups from '~/components/GlobalPopups.vue'
|
|||||||
import ConfirmDialog from '~/components/ConfirmDialog.vue'
|
import ConfirmDialog from '~/components/ConfirmDialog.vue'
|
||||||
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
|
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import { checkToken } from '~/utils/auth'
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const menuVisible = ref(!isMobile.value)
|
const menuVisible = ref(!isMobile.value)
|
||||||
|
|
||||||
|
await checkToken()
|
||||||
|
|
||||||
const showNewPostIcon = computed(() => useRoute().path === '/')
|
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||||
|
|
||||||
const hideMenu = computed(() => {
|
const hideMenu = computed(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
--primary-color: rgb(10, 110, 120);
|
--primary-color: rgb(10, 110, 120);
|
||||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||||
--secondary-color: rgb(255, 255, 255);
|
--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);
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-height: 60px;
|
--header-height: 60px;
|
||||||
--header-background-color: white;
|
--header-background-color: white;
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
--header-border-color: #555;
|
--header-border-color: #555;
|
||||||
--primary-color: rgb(17, 182, 197);
|
--primary-color: rgb(17, 182, 197);
|
||||||
--primary-color-hover: rgb(13, 137, 151);
|
--primary-color-hover: rgb(13, 137, 151);
|
||||||
|
--secondary-color-hover: rgba(17, 182, 197, 0.238);
|
||||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-text-color: white;
|
--header-text-color: white;
|
||||||
--app-menu-background-color: #333;
|
--app-menu-background-color: #333;
|
||||||
@@ -179,7 +180,7 @@ body {
|
|||||||
|
|
||||||
.info-content-text pre .line-numbers {
|
.info-content-text pre .line-numbers {
|
||||||
counter-reset: line-number 0;
|
counter-reset: line-number 0;
|
||||||
white-space: nowrap; /* 禁止数字换行 */
|
white-space: nowrap; /* 禁止数字换行 */
|
||||||
font-variant-numeric: tabular-nums; /* 数字等宽 */
|
font-variant-numeric: tabular-nums; /* 数字等宽 */
|
||||||
/* width: 2em; */
|
/* width: 2em; */
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -205,7 +206,6 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: var(--code-highlight-background-color);
|
background-color: var(--code-highlight-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
white-space: pre; /* 禁止自动换行 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-code-btn {
|
.copy-code-btn {
|
||||||
@@ -344,7 +344,7 @@ body {
|
|||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*处理iframe视频标签*/
|
/*处理iframe视频标签*/
|
||||||
.info-content-text iframe {
|
.info-content-text iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -370,7 +370,10 @@ body {
|
|||||||
.d2h-code-line {
|
.d2h-code-line {
|
||||||
padding-left: 10px !important;
|
padding-left: 10px !important;
|
||||||
}
|
}
|
||||||
|
/* 手机端不换行 */
|
||||||
|
.info-content-text code {
|
||||||
|
white-space: pre; /* 禁止自动换行 */
|
||||||
|
}
|
||||||
/* .d2h-diff-table {
|
/* .d2h-diff-table {
|
||||||
font-size: 6px !important;
|
font-size: 6px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { useAttrs } from 'vue'
|
import { useAttrs } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
src: { type: String, required: true },
|
src: { type: String, default: '' },
|
||||||
alt: { type: String, default: '' },
|
alt: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,9 +39,6 @@ const placeholder = computed(() => {
|
|||||||
function onLoad() {
|
function onLoad() {
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
function onError() {
|
|
||||||
loaded.value = true
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
187
frontend_nuxt/components/BaseItemGroup.vue
Normal file
187
frontend_nuxt/components/BaseItemGroup.vue
Normal 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>
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLink
|
<div
|
||||||
:to="resolvedLink"
|
|
||||||
class="base-user-avatar"
|
class="base-user-avatar"
|
||||||
:class="wrapperClass"
|
:class="wrapperClass"
|
||||||
:style="wrapperStyle"
|
:style="wrapperStyle"
|
||||||
v-bind="wrapperAttrs"
|
v-bind="wrapperAttrs"
|
||||||
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
|
<BaseImage :src="props.src" :alt="altText" class="base-user-avatar-img" />
|
||||||
</NuxtLink>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { useAttrs } from 'vue'
|
import { useAttrs } from 'vue'
|
||||||
import BaseImage from './BaseImage.vue'
|
import BaseImage from './BaseImage.vue'
|
||||||
|
|
||||||
const DEFAULT_AVATAR = '/default-avatar.svg'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
userId: {
|
userId: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
@@ -50,15 +48,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
|
|
||||||
const currentSrc = ref(props.src || DEFAULT_AVATAR)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.src,
|
|
||||||
(value) => {
|
|
||||||
currentSrc.value = value || DEFAULT_AVATAR
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolvedLink = computed(() => {
|
const resolvedLink = computed(() => {
|
||||||
if (props.to) return props.to
|
if (props.to) return props.to
|
||||||
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
|
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
|
||||||
@@ -70,10 +59,16 @@ const resolvedLink = computed(() => {
|
|||||||
const altText = computed(() => props.alt || '用户头像')
|
const altText = computed(() => props.alt || '用户头像')
|
||||||
|
|
||||||
const sizeStyle = computed(() => {
|
const sizeStyle = computed(() => {
|
||||||
if (!props.width && props.width !== 0) return null
|
var style = {}
|
||||||
const value = typeof props.width === 'number' ? `${props.width}px` : props.width
|
|
||||||
if (!value) return null
|
if (props.width > 0) {
|
||||||
return { width: value, height: value }
|
style.width = `${props.width}px`
|
||||||
|
}
|
||||||
|
if (props.height > 0) {
|
||||||
|
style.height = `${props.height}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
return style
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapperStyle = computed(() => {
|
const wrapperStyle = computed(() => {
|
||||||
@@ -88,10 +83,9 @@ const wrapperAttrs = computed(() => {
|
|||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
|
|
||||||
function onError() {
|
const handleClick = () => {
|
||||||
if (currentSrc.value !== DEFAULT_AVATAR) {
|
if (props.disableLink) return
|
||||||
currentSrc.value = DEFAULT_AVATAR
|
navigateTo(resolvedLink.value)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -109,7 +103,7 @@ function onError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.base-user-avatar:hover {
|
.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);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -488,6 +488,16 @@ const handleContentClick = (e) => {
|
|||||||
font-weight: bold;
|
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 {
|
.medal-name {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
|
|||||||
319
frontend_nuxt/components/DonateGroup.vue
Normal file
319
frontend_nuxt/components/DonateGroup.vue
Normal 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>
|
||||||
@@ -78,7 +78,9 @@
|
|||||||
<div class="header-icon-item" @click="goToMessages">
|
<div class="header-icon-item" @click="goToMessages">
|
||||||
<message-emoji class="header-icon" />
|
<message-emoji class="header-icon" />
|
||||||
<span class="header-label">消息</span>
|
<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>
|
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
@@ -89,10 +91,9 @@
|
|||||||
<BaseUserAvatar
|
<BaseUserAvatar
|
||||||
class="avatar-img"
|
class="avatar-img"
|
||||||
:user-id="authState.userId"
|
:user-id="authState.userId"
|
||||||
:src="avatar"
|
:src="authState.avatar"
|
||||||
alt="avatar"
|
|
||||||
:width="32"
|
|
||||||
:disable-link="true"
|
:disable-link="true"
|
||||||
|
:width="32"
|
||||||
/>
|
/>
|
||||||
<down />
|
<down />
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +118,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
|
|||||||
import ToolTip from '~/components/ToolTip.vue'
|
import ToolTip from '~/components/ToolTip.vue'
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
import BaseUserAvatar from '~/components/BaseUserAvatar.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 { useUnreadCount } from '~/composables/useUnreadCount'
|
||||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
@@ -139,13 +140,11 @@ const isLogin = computed(() => authState.loggedIn)
|
|||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
||||||
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
||||||
const avatar = ref('')
|
|
||||||
const showSearch = ref(false)
|
const showSearch = ref(false)
|
||||||
const searchDropdown = ref(null)
|
const searchDropdown = ref(null)
|
||||||
const userMenu = ref(null)
|
const userMenu = ref(null)
|
||||||
const menuBtn = ref(null)
|
const menuBtn = ref(null)
|
||||||
const isCopying = ref(false)
|
const isCopying = ref(false)
|
||||||
|
|
||||||
const onlineCount = ref(0)
|
const onlineCount = ref(0)
|
||||||
|
|
||||||
// 心跳检测
|
// 心跳检测
|
||||||
@@ -208,7 +207,7 @@ const copyInviteLink = async () => {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
isCopying.value = false // 🔥 修复:未登录时立即复原状态
|
isCopying.value = false // 🔥 修复:未登录时立即复原状态
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -252,17 +251,7 @@ const copyRssLink = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goToProfile = async () => {
|
const goToProfile = async () => {
|
||||||
if (!authState.loggedIn) {
|
let id = authState.username || authState.id
|
||||||
navigateTo('/login', { replace: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let id = authState.username || authState.userId
|
|
||||||
if (!id) {
|
|
||||||
const user = await loadCurrentUser()
|
|
||||||
if (user) {
|
|
||||||
id = user.username || user.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (id) {
|
if (id) {
|
||||||
navigateTo(`/users/${id}`, { replace: true })
|
navigateTo(`/users/${id}`, { replace: true })
|
||||||
}
|
}
|
||||||
@@ -306,14 +295,6 @@ const iconClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const updateAvatar = async () => {
|
|
||||||
if (authState.loggedIn) {
|
|
||||||
const user = await loadCurrentUser()
|
|
||||||
if (user && user.avatar) {
|
|
||||||
avatar.value = user.avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updateUnread = async () => {
|
const updateUnread = async () => {
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
fetchUnreadCount()
|
fetchUnreadCount()
|
||||||
@@ -323,17 +304,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateAvatar()
|
|
||||||
await updateUnread()
|
await updateUnread()
|
||||||
|
|
||||||
watch(
|
|
||||||
() => authState.loggedIn,
|
|
||||||
async (isLoggedIn) => {
|
|
||||||
await updateAvatar()
|
|
||||||
await updateUnread()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 新增的在线人数逻辑
|
// 新增的在线人数逻辑
|
||||||
sendPing()
|
sendPing()
|
||||||
fetchCount()
|
fetchCount()
|
||||||
@@ -482,7 +454,6 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.invite_text:hover {
|
.invite_text:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -543,7 +514,10 @@ onMounted(async () => {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
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 {
|
.header-icon-item:hover {
|
||||||
@@ -572,15 +546,14 @@ onMounted(async () => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
right: -6px;
|
right: -6px;
|
||||||
color: var(--primary-color); /* 🔹 使用主题主色 */
|
color: var(--primary-color); /* 🔹 使用主题主色 */
|
||||||
background: none; /* 🔹 去掉背景 */
|
background: none; /* 🔹 去掉背景 */
|
||||||
font-size: 11px; /* 字体稍微大一点以便清晰 */
|
font-size: 11px; /* 字体稍微大一点以便清晰 */
|
||||||
font-weight: 600; /* 加一点权重让数字更醒目 */
|
font-weight: 600; /* 加一点权重让数字更醒目 */
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0; /* 去掉内边距 */
|
padding: 0; /* 去掉内边距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes rss-glow {
|
@keyframes rss-glow {
|
||||||
0% {
|
0% {
|
||||||
text-shadow: 0 0 0px var(--primary-color);
|
text-shadow: 0 0 0px var(--primary-color);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mark-read-button:hover {
|
.mark-read-button:hover {
|
||||||
@@ -53,6 +54,7 @@ export default {
|
|||||||
|
|
||||||
.has-read-button {
|
.has-read-button {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
|
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
|
||||||
>系统已「精密计算」抽奖结果 (=゚ω゚)ノ</span
|
>系统已「精密计算」抽奖结果 (=゚ω゚)ノ</span
|
||||||
>
|
>
|
||||||
|
<span v-else-if="log.type === 'DONATE'" class="change-log-content"
|
||||||
|
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="change-log-time">{{ log.time }}</div>
|
<div class="change-log-time">{{ log.time }}</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -18,9 +18,11 @@
|
|||||||
<div>{{ counts[r.type] }}</div>
|
<div>{{ counts[r.type] }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
<ToolTip content="发表心情" placement="bottom">
|
||||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||||
</div>
|
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||||
|
</div>
|
||||||
|
</ToolTip>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="displayedReactions.length">
|
<template v-else-if="displayedReactions.length">
|
||||||
<div
|
<div
|
||||||
@@ -164,7 +166,7 @@ const updatePanelInlineStyle = () => {
|
|||||||
if (!panelVisible.value) return
|
if (!panelVisible.value) return
|
||||||
const panelEl = reactionsPanelRef.value
|
const panelEl = reactionsPanelRef.value
|
||||||
if (!panelEl) return
|
if (!panelEl) return
|
||||||
const parentEl = panelEl.closest('.reactions-container')?.parentElement
|
const parentEl = panelEl.closest('.reactions-container')?.parentElement?.parentElement
|
||||||
if (!parentEl) return
|
if (!parentEl) return
|
||||||
const parentWidth = parentEl.clientWidth - 20
|
const parentWidth = parentEl.clientWidth - 20
|
||||||
panelInlineStyle.value = {
|
panelInlineStyle.value = {
|
||||||
@@ -320,11 +322,12 @@ onBeforeUnmount(() => {
|
|||||||
.reactions-count {
|
.reactions-count {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-panel {
|
.reactions-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 40px;
|
bottom: 35px;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -357,7 +360,6 @@ onBeforeUnmount(() => {
|
|||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
<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>
|
</NuxtLink>
|
||||||
<div class="article-info-container main-item">
|
<div class="article-info-container main-item">
|
||||||
<ArticleCategory :category="article.category" />
|
<ArticleCategory :category="article.category" />
|
||||||
@@ -143,6 +143,7 @@ import { useIsMobile } from '~/utils/screen'
|
|||||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
|
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
|
||||||
|
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
|
||||||
useHead({
|
useHead({
|
||||||
title: 'OpenIsle - 全面开源的自由社区',
|
title: 'OpenIsle - 全面开源的自由社区',
|
||||||
meta: [
|
meta: [
|
||||||
@@ -378,27 +379,6 @@ onBeforeUnmount(() => {
|
|||||||
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
||||||
const ioKey = computed(() => asyncKey.value.join('::'))
|
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]) => {
|
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
import { setToken } from '~/utils/auth'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||||
import { registerPush } from '~/utils/push'
|
import { registerPush } from '~/utils/push'
|
||||||
@@ -61,7 +61,6 @@ const submitLogin = async () => {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush()
|
||||||
await navigateTo('/', { replace: true })
|
await navigateTo('/', { replace: true })
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
>
|
>
|
||||||
<div class="conversation-avatar">
|
<div class="conversation-avatar">
|
||||||
<BaseImage
|
<BaseImage
|
||||||
:src="ch.avatar || '/default-avatar.svg'"
|
:src="ch.avatar"
|
||||||
:alt="ch.name"
|
:alt="ch.name"
|
||||||
class="avatar-img"
|
class="avatar-img"
|
||||||
@error="handleAvatarError"
|
@error="handleAvatarError"
|
||||||
@@ -194,7 +194,7 @@ function formatTime(timeString) {
|
|||||||
|
|
||||||
// 头像加载失败处理
|
// 头像加载失败处理
|
||||||
function handleAvatarError(event) {
|
function handleAvatarError(event) {
|
||||||
event.target.src = '/default-avatar.svg'
|
event.target.src = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChannels() {
|
async function fetchChannels() {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.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>
|
||||||
</span>
|
</span>
|
||||||
回复了
|
回复了
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
进行了表态
|
进行了表态
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.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>
|
||||||
回复了
|
回复了
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -323,7 +323,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -542,6 +542,27 @@
|
|||||||
被收录为精选
|
被收录为精选
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</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'">
|
<template v-else-if="item.type === 'POST_DELETED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
管理员
|
管理员
|
||||||
@@ -556,7 +577,7 @@
|
|||||||
</template>
|
</template>
|
||||||
删除了您的帖子
|
删除了您的帖子
|
||||||
<span class="notif-content-text">
|
<span class="notif-content-text">
|
||||||
{{ stripMarkdownLength(item.content, 100) }}
|
<span v-html="stripMarkdownWithTiebaMoji(item.content, 500)"></span>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -586,7 +607,7 @@ import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
|||||||
import BaseTabs from '~/components/BaseTabs.vue'
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
|
||||||
import {
|
import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchUnreadCount,
|
fetchUnreadCount,
|
||||||
|
|||||||
@@ -184,6 +184,27 @@
|
|||||||
}}</NuxtLink>
|
}}</NuxtLink>
|
||||||
参与,获得 {{ item.amount }} 积分
|
参与,获得 {{ item.amount }} 积分
|
||||||
</template>
|
</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>
|
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||||
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +269,8 @@ const iconMap = {
|
|||||||
FEATURE: 'star',
|
FEATURE: 'star',
|
||||||
LOTTERY_JOIN: 'medal-one',
|
LOTTERY_JOIN: 'medal-one',
|
||||||
LOTTERY_REWARD: 'fireworks',
|
LOTTERY_REWARD: 'fireworks',
|
||||||
|
DONATE_SENT: 'paper-money-two',
|
||||||
|
DONATE_RECEIVED: 'paper-money-two',
|
||||||
POST_LIKE_CANCELLED: 'clear-icon',
|
POST_LIKE_CANCELLED: 'clear-icon',
|
||||||
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,12 +92,15 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup
|
<div class="option-container">
|
||||||
ref="postReactionsGroupRef"
|
<ReactionsGroup
|
||||||
v-model="postReactions"
|
ref="postReactionsGroupRef"
|
||||||
content-type="post"
|
v-model="postReactions"
|
||||||
:content-id="postId"
|
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="article-footer-actions">
|
||||||
<div
|
<div
|
||||||
class="reaction-action like-action"
|
class="reaction-action like-action"
|
||||||
@@ -211,6 +214,7 @@ import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
|
|||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
|
import DonateGroup from '~/components/DonateGroup.vue'
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
import PostLottery from '~/components/PostLottery.vue'
|
import PostLottery from '~/components/PostLottery.vue'
|
||||||
import PostPoll from '~/components/PostPoll.vue'
|
import PostPoll from '~/components/PostPoll.vue'
|
||||||
@@ -408,6 +412,8 @@ const changeLogIcon = (l) => {
|
|||||||
return 'check-one'
|
return 'check-one'
|
||||||
} else if (l.type === 'LOTTERY_RESULT') {
|
} else if (l.type === 'LOTTERY_RESULT') {
|
||||||
return 'gift'
|
return 'gift'
|
||||||
|
} else if (l.type === 'DONATE') {
|
||||||
|
return 'financing'
|
||||||
} else {
|
} else {
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
@@ -432,6 +438,7 @@ const mapChangeLog = (l) => ({
|
|||||||
newCategory: l.newCategory,
|
newCategory: l.newCategory,
|
||||||
oldTags: l.oldTags,
|
oldTags: l.oldTags,
|
||||||
newTags: l.newTags,
|
newTags: l.newTags,
|
||||||
|
amount: l.amount,
|
||||||
icon: changeLogIcon(l),
|
icon: changeLogIcon(l),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1276,6 +1283,14 @@ onMounted(async () => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-footer-actions {
|
.article-footer-actions {
|
||||||
@@ -1367,6 +1382,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.article-footer-container {
|
.article-footer-container {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
margin-bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { loadCurrentUser, setToken } from '~/utils/auth'
|
import { setToken } from '~/utils/auth'
|
||||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -172,7 +172,6 @@ const verifyCode = async () => {
|
|||||||
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
|
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
|
||||||
toast.success('注册成功')
|
toast.success('注册成功')
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
loadCurrentUser()
|
|
||||||
navigateTo('/', { replace: true })
|
navigateTo('/', { replace: true })
|
||||||
} else if (data.reason_code === 'VERIFIED') {
|
} else if (data.reason_code === 'VERIFIED') {
|
||||||
if (registerMode.value === 'WHITELIST') {
|
if (registerMode.value === 'WHITELIST') {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
Dislike,
|
Dislike,
|
||||||
CheckOne,
|
CheckOne,
|
||||||
Share,
|
Share,
|
||||||
|
Financing,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
@@ -163,4 +164,5 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('Dislike', Dislike)
|
nuxtApp.vueApp.component('Dislike', Dislike)
|
||||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||||
nuxtApp.vueApp.component('Share', Share)
|
nuxtApp.vueApp.component('Share', Share)
|
||||||
|
nuxtApp.vueApp.component('Financing', Financing)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 |
@@ -1,33 +1,28 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
const TOKEN_KEY = 'token'
|
const TOKEN_KEY = 'token'
|
||||||
const USER_ID_KEY = 'userId'
|
|
||||||
const USERNAME_KEY = 'username'
|
|
||||||
const ROLE_KEY = 'role'
|
|
||||||
|
|
||||||
export const authState = reactive({
|
export const authState = reactive({
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
username: null,
|
username: null,
|
||||||
role: null,
|
role: null,
|
||||||
|
avatar: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
authState.loggedIn =
|
authState.loggedIn =
|
||||||
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
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() {
|
export function getToken() {
|
||||||
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
|
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(token) {
|
export async function setToken(token) {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
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) {
|
if (import.meta.client) {
|
||||||
authState.userId = id
|
authState.userId = user.id
|
||||||
authState.username = username
|
authState.username = user.username
|
||||||
if (arguments[0] && arguments[0].role) {
|
authState.avatar = user.avatar
|
||||||
authState.role = arguments[0].role
|
authState.role = user.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearUserInfo() {
|
export function clearUserInfo() {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
localStorage.removeItem(USER_ID_KEY)
|
|
||||||
localStorage.removeItem(USERNAME_KEY)
|
|
||||||
localStorage.removeItem(ROLE_KEY)
|
|
||||||
authState.userId = null
|
authState.userId = null
|
||||||
authState.username = null
|
authState.username = null
|
||||||
|
authState.avatar = null
|
||||||
authState.role = null
|
authState.role = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,9 +71,11 @@ export async function fetchCurrentUser() {
|
|||||||
export async function loadCurrentUser() {
|
export async function loadCurrentUser() {
|
||||||
const user = await fetchCurrentUser()
|
const user = await fetchCurrentUser()
|
||||||
if (user) {
|
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() {
|
export function isLogin() {
|
||||||
@@ -100,10 +91,12 @@ export async function checkToken() {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
authState.loggedIn = res.ok
|
if (res.ok) {
|
||||||
return res.ok
|
await setToken(token)
|
||||||
|
} else {
|
||||||
|
clearToken()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authState.loggedIn = false
|
clearToken()
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function discordAuthorize(inviteToken = '') {
|
export function discordAuthorize(inviteToken = '') {
|
||||||
@@ -47,7 +47,6 @@ export async function discordExchange(code, inviteToken = '', reason = '') {
|
|||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function githubAuthorize(inviteToken = '') {
|
export function githubAuthorize(inviteToken = '') {
|
||||||
@@ -45,7 +45,6 @@ export async function githubExchange(code, inviteToken = '', reason = '') {
|
|||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export async function googleGetIdToken() {
|
export async function googleGetIdToken() {
|
||||||
@@ -79,7 +79,6 @@ export async function googleAuthWithToken(
|
|||||||
|
|
||||||
if (res.ok && data && data.token) {
|
if (res.ok && data && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
if (typeof redirect_success === 'function') redirect_success()
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
|
|||||||
@@ -265,3 +265,26 @@ export function stripMarkdownLength(text, length) {
|
|||||||
}
|
}
|
||||||
return plain.slice(0, 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
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const iconMap = {
|
|||||||
MENTION: 'HashtagKey',
|
MENTION: 'HashtagKey',
|
||||||
POST_DELETED: 'ClearIcon',
|
POST_DELETED: 'ClearIcon',
|
||||||
POST_FEATURED: 'Star',
|
POST_FEATURED: 'Star',
|
||||||
|
DONATION: 'PaperMoneyTwo',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUnreadCount() {
|
export async function fetchUnreadCount() {
|
||||||
@@ -334,6 +335,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') {
|
} else if (n.type === 'REGISTER_REQUEST') {
|
||||||
arr.push({
|
arr.push({
|
||||||
...n,
|
...n,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function telegramAuthorize(inviteToken = '') {
|
export function telegramAuthorize(inviteToken = '') {
|
||||||
@@ -34,7 +34,6 @@ export async function telegramExchange(authData, inviteToken = '', reason = '')
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
function generateCodeVerifier() {
|
function generateCodeVerifier() {
|
||||||
@@ -99,7 +99,6 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
Reference in New Issue
Block a user