mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Compare commits
122 Commits
feature/fi
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c187cf2cd | ||
|
|
0b6d4f9709 | ||
|
|
cf3b6d8fc7 | ||
|
|
8d98c876d2 | ||
|
|
df4df1933a | ||
|
|
7507f1bb03 | ||
|
|
9b4c36c76a | ||
|
|
edfc81aeb0 | ||
|
|
7bd1225b27 | ||
|
|
2dd56e27af | ||
|
|
c3ecef3609 | ||
|
|
efc74d0f77 | ||
|
|
f27cb5c703 | ||
|
|
a756c2fab3 | ||
|
|
4e2171a8a6 | ||
|
|
bcbdff8768 | ||
|
|
b976a1f46f | ||
|
|
b9fd9711de | ||
|
|
642a527dcf | ||
|
|
88afcc5a8e | ||
|
|
2c5462cd97 | ||
|
|
2f29946b11 | ||
|
|
e27aa34cfd | ||
|
|
2322b2da15 | ||
|
|
79261054f9 | ||
|
|
86633e1f21 | ||
|
|
784598a6f0 | ||
|
|
fdad0e5d34 | ||
|
|
ebf63c4072 | ||
|
|
354d6bdaf9 | ||
|
|
d9aebdebdc | ||
|
|
d6f6495b35 | ||
|
|
300f8705ef | ||
|
|
1f74a29dce | ||
|
|
27ef792b11 | ||
|
|
8dd2d59617 | ||
|
|
077ba448d7 | ||
|
|
9ce85f2769 | ||
|
|
f5557cbf08 | ||
|
|
e042c499e1 | ||
|
|
e01afb168c | ||
|
|
c1d81eb1d1 | ||
|
|
2b0b429866 | ||
|
|
8ea85d78ee | ||
|
|
3b506fe8a8 | ||
|
|
3cc7a4c01a | ||
|
|
2e749a5672 | ||
|
|
7d553d7750 | ||
|
|
16105cef54 | ||
|
|
2b824d94f2 | ||
|
|
00d3c563e2 | ||
|
|
b26891261c | ||
|
|
c1d19b854b | ||
|
|
72e7ccf262 | ||
|
|
84ca6fd28c | ||
|
|
d1c148c5c4 | ||
|
|
ef58630dae | ||
|
|
f025e82e7c | ||
|
|
4380a988f7 | ||
|
|
2899f7af48 | ||
|
|
d4b05256a3 | ||
|
|
57a26e375d | ||
|
|
8a202c4fba | ||
|
|
089b2a3f5f | ||
|
|
0b3d7a21d5 | ||
|
|
fe8a705a28 | ||
|
|
974c7ba83e | ||
|
|
f2937d735d | ||
|
|
423248c574 | ||
|
|
5126cfda8c | ||
|
|
e009875797 | ||
|
|
04ff17f796 | ||
|
|
e9c9fbd742 | ||
|
|
b385945c2d | ||
|
|
24cbed2eda | ||
|
|
ba073b71a6 | ||
|
|
5ff098ea21 | ||
|
|
f6713b956e | ||
|
|
b8ea12646f | ||
|
|
e573e54c2b | ||
|
|
8ec005d392 | ||
|
|
b1f92f61a6 | ||
|
|
824b4dd8aa | ||
|
|
6b08db7e58 | ||
|
|
6f3830b3f7 | ||
|
|
d70dad723f | ||
|
|
2cf89e4802 | ||
|
|
1fc6460ae0 | ||
|
|
a04e5c2f6f | ||
|
|
77b26937f5 | ||
|
|
a1134b9d4b | ||
|
|
600f6ac1d1 | ||
|
|
9ad50b35c9 | ||
|
|
867ee3907b | ||
|
|
58fcd42745 | ||
|
|
0ee62a3a04 | ||
|
|
f0bc7a22a0 | ||
|
|
f6c0c8e226 | ||
|
|
8f3c0d6710 | ||
|
|
4f738778db | ||
|
|
84b45f785d | ||
|
|
df56d7e885 | ||
|
|
76176e135c | ||
|
|
ab87e0e51c | ||
|
|
5346a063bf | ||
|
|
e53f2130b8 | ||
|
|
1e87e9252d | ||
|
|
3fc4d29dce | ||
|
|
bcdac9d9b2 | ||
|
|
ea9710d16f | ||
|
|
47134cadc2 | ||
|
|
1a1b20b9cf | ||
|
|
b63ebb8fae | ||
|
|
e0f7299a86 | ||
|
|
1f9ae8d057 | ||
|
|
da1ad73cf6 | ||
|
|
53c603f33a | ||
|
|
06f86f2b21 | ||
|
|
0058f20b1e | ||
|
|
3dbcd2ac4d | ||
|
|
08239a16b8 | ||
|
|
43d4c9be43 |
23
.github/workflows/deploy-staging.yml
vendored
Normal file
23
.github/workflows/deploy-staging.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Staging CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Deploy
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to Server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: bash /opt/openisle/deploy-staging.sh
|
||||
|
||||
20
.github/workflows/deploy.yml
vendored
20
.github/workflows/deploy.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -13,22 +13,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# - uses: actions/setup-java@v4
|
||||
# with:
|
||||
# java-version: '17'
|
||||
# distribution: 'temurin'
|
||||
|
||||
# - run: mvn -B clean package -DskipTests
|
||||
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20'
|
||||
|
||||
# - run: |
|
||||
# cd open-isle-cli
|
||||
# npm ci
|
||||
# npm run build
|
||||
|
||||
- name: Deploy to Server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
|
||||
25
README.md
25
README.md
@@ -10,7 +10,7 @@
|
||||
|
||||
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
||||
|
||||
## 🚀 部署
|
||||
## 🚧 开发
|
||||
|
||||
### 后端
|
||||
|
||||
@@ -20,9 +20,26 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
|
||||
### 前端
|
||||
|
||||
1. `cd open-isle-cli`
|
||||
2. 执行 `npm install`
|
||||
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
||||
1. 进入前端目录
|
||||
```bash
|
||||
cd frontend_nuxt
|
||||
```
|
||||
2. 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. 启动开发服务
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
生产版本使用如下命令编译:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
会在 `.output` 目录生成文件,配合线上网站方式部署
|
||||
|
||||
## ✨ 项目特点
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.model.PointGood;
|
||||
import com.openisle.repository.PointGoodRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Initialize default point mall goods. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PointGoodInitializer implements CommandLineRunner {
|
||||
private final PointGoodRepository pointGoodRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (pointGoodRepository.count() == 0) {
|
||||
PointGood g1 = new PointGood();
|
||||
g1.setName("GPT Plus 1 个月");
|
||||
g1.setCost(20000);
|
||||
g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png");
|
||||
pointGoodRepository.save(g1);
|
||||
|
||||
PointGood g2 = new PointGood();
|
||||
g2.setName("奶茶");
|
||||
g2.setCost(5000);
|
||||
g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
|
||||
pointGoodRepository.save(g2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,14 +75,16 @@ public class SecurityConfig {
|
||||
cfg.setAllowedOrigins(List.of(
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost",
|
||||
"http://30.211.97.238:3000",
|
||||
"http://30.211.97.238",
|
||||
"http://192.168.7.70",
|
||||
"http://192.168.7.70:8080",
|
||||
"http://192.168.7.98",
|
||||
"http://192.168.7.98:3000",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
@@ -117,6 +119,8 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
||||
@@ -149,6 +153,7 @@ public class SecurityConfig {
|
||||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||
uri.startsWith("/api/point-goods") ||
|
||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@@ -44,8 +45,11 @@ public class CategoryController {
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryDto> list() {
|
||||
return categoryService.listCategories().stream()
|
||||
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId())))
|
||||
List<Category> all = categoryService.listCategories();
|
||||
List<Long> ids = all.stream().map(Category::getId).toList();
|
||||
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
|
||||
return all.stream()
|
||||
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.PointGoodDto;
|
||||
import com.openisle.dto.PointRedeemRequest;
|
||||
import com.openisle.mapper.PointGoodMapper;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.PointMallService;
|
||||
import com.openisle.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** REST controller for point mall. */
|
||||
@RestController
|
||||
@RequestMapping("/api/point-goods")
|
||||
@RequiredArgsConstructor
|
||||
public class PointMallController {
|
||||
private final PointMallService pointMallService;
|
||||
private final UserService userService;
|
||||
private final PointGoodMapper pointGoodMapper;
|
||||
|
||||
@GetMapping
|
||||
public List<PointGoodDto> list() {
|
||||
return pointMallService.listGoods().stream()
|
||||
.map(pointGoodMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/redeem")
|
||||
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
|
||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
||||
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
|
||||
return Map.of("point", point);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@@ -62,8 +63,11 @@ public class TagController {
|
||||
@GetMapping
|
||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
List<TagDto> dtos = tagService.searchTags(keyword).stream()
|
||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
||||
List<Tag> tags = tagService.searchTags(keyword);
|
||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||
List<TagDto> dtos = tags.stream()
|
||||
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||
|
||||
12
backend/src/main/java/com/openisle/dto/PointGoodDto.java
Normal file
12
backend/src/main/java/com/openisle/dto/PointGoodDto.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Point mall good info. */
|
||||
@Data
|
||||
public class PointGoodDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private int cost;
|
||||
private String image;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Request to redeem a point mall good. */
|
||||
@Data
|
||||
public class PointRedeemRequest {
|
||||
private Long goodId;
|
||||
private String contact;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.openisle.mapper;
|
||||
|
||||
import com.openisle.dto.PointGoodDto;
|
||||
import com.openisle.model.PointGood;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Mapper for point mall goods. */
|
||||
@Component
|
||||
public class PointGoodMapper {
|
||||
public PointGoodDto toDto(PointGood good) {
|
||||
PointGoodDto dto = new PointGoodDto();
|
||||
dto.setId(good.getId());
|
||||
dto.setName(good.getName());
|
||||
dto.setCost(good.getCost());
|
||||
dto.setImage(good.getImage());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class Notification {
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(nullable = false, length = 50)
|
||||
private NotificationType type;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
|
||||
@@ -32,6 +32,8 @@ public enum NotificationType {
|
||||
REGISTER_REQUEST,
|
||||
/** A user redeemed an activity reward */
|
||||
ACTIVITY_REDEEM,
|
||||
/** A user redeemed a point good */
|
||||
POINT_REDEEM,
|
||||
/** You won a lottery post */
|
||||
LOTTERY_WIN,
|
||||
/** Your lottery post was drawn */
|
||||
|
||||
26
backend/src/main/java/com/openisle/model/PointGood.java
Normal file
26
backend/src/main/java/com/openisle/model/PointGood.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Item available in the point mall. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "point_goods")
|
||||
public class PointGood {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int cost;
|
||||
|
||||
private String image;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PointGood;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Repository for point mall goods. */
|
||||
public interface PointGoodRepository extends JpaRepository<PointGood, Long> {
|
||||
}
|
||||
@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
|
||||
long countByCategory_Id(Long categoryId);
|
||||
|
||||
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
|
||||
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
|
||||
|
||||
long countDistinctByTags_Id(Long tagId);
|
||||
|
||||
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
|
||||
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
|
||||
|
||||
long countByAuthor_Id(Long userId);
|
||||
|
||||
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
||||
|
||||
@@ -141,6 +141,19 @@ public class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications for all admins when a user redeems a point good.
|
||||
* Old redeem notifications from the same user are removed first.
|
||||
*/
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void createPointRedeemNotifications(User user, String content) {
|
||||
notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
|
||||
for (User admin : userRepository.findByRole(Role.ADMIN)) {
|
||||
createNotification(admin, NotificationType.POINT_REDEEM, null, null,
|
||||
null, user, null, content);
|
||||
}
|
||||
}
|
||||
|
||||
public List<NotificationPreferenceDto> listPreferences(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.model.PointGood;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PointGoodRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Service for point mall operations. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PointMallService {
|
||||
private final PointGoodRepository pointGoodRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public List<PointGood> listGoods() {
|
||||
return pointGoodRepository.findAll();
|
||||
}
|
||||
|
||||
public int redeem(User user, Long goodId, String contact) {
|
||||
PointGood good = pointGoodRepository.findById(goodId)
|
||||
.orElseThrow(() -> new NotFoundException("Good not found"));
|
||||
if (user.getPoint() < good.getCost()) {
|
||||
throw new FieldException("point", "Insufficient points");
|
||||
}
|
||||
user.setPoint(user.getPoint() - good.getCost());
|
||||
userRepository.save(user);
|
||||
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
|
||||
return user.getPoint();
|
||||
}
|
||||
}
|
||||
@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
@@ -567,10 +566,31 @@ public class PostService {
|
||||
return postRepository.countByCategory_Id(categoryId);
|
||||
}
|
||||
|
||||
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
|
||||
Map<Long, Long> result = new HashMap<>();
|
||||
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
|
||||
dbResult.forEach(r -> {
|
||||
result.put(((Long)r[0]), ((Long)r[1]));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public long countPostsByTag(Long tagId) {
|
||||
return postRepository.countDistinctByTags_Id(tagId);
|
||||
}
|
||||
|
||||
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
|
||||
Map<Long, Long> result = new HashMap<>();
|
||||
if (CollectionUtils.isEmpty(tagIds)) {
|
||||
return result;
|
||||
}
|
||||
var dbResult = postRepository.countPostsByTagIds(tagIds);
|
||||
dbResult.forEach(r -> {
|
||||
result.put(((Long)r[0]), ((Long)r[1]));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
||||
return posts.stream()
|
||||
.sorted(java.util.Comparator
|
||||
|
||||
@@ -144,6 +144,30 @@ class NotificationServiceTest {
|
||||
verify(nRepo).save(any(Notification.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPointRedeemNotificationsDeletesOldOnes() {
|
||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||
UserRepository uRepo = mock(UserRepository.class);
|
||||
ReactionRepository rRepo = mock(ReactionRepository.class);
|
||||
EmailSender email = mock(EmailSender.class);
|
||||
PushNotificationService push = mock(PushNotificationService.class);
|
||||
Executor executor = Runnable::run;
|
||||
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
|
||||
|
||||
User admin = new User();
|
||||
admin.setId(10L);
|
||||
User user = new User();
|
||||
user.setId(20L);
|
||||
|
||||
when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin));
|
||||
|
||||
service.createPointRedeemNotifications(user, "contact");
|
||||
|
||||
verify(nRepo).deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
|
||||
verify(nRepo).save(any(Notification.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createNotificationSendsEmailForCommentReply() {
|
||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
; 本地部署后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
|
||||
; 预发环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
|
||||
16
frontend_nuxt/.env.staging.example
Normal file
16
frontend_nuxt/.env.staging.example
Normal file
@@ -0,0 +1,16 @@
|
||||
; 本地部署后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
|
||||
; 预发环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
@@ -15,62 +15,77 @@
|
||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||
<NuxtPage keepalive />
|
||||
</div>
|
||||
|
||||
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
</div>
|
||||
</div>
|
||||
<GlobalPopups />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||
import MenuComponent from '~/components/MenuComponent.vue'
|
||||
|
||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
||||
setup() {
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
const hideMenu = computed(() => {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
|
||||
const header = useTemplateRef('header')
|
||||
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
menuVisible.value = window.innerWidth > 768
|
||||
}
|
||||
})
|
||||
const hideMenu = computed(() => {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
|
||||
const handleMenuOutside = (event) => {
|
||||
const btn = header.value.$refs.menuBtn
|
||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||
return // 如果是菜单按钮的点击,不处理关闭
|
||||
}
|
||||
const header = useTemplateRef('header')
|
||||
|
||||
if (isMobile.value) {
|
||||
menuVisible.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
menuVisible.value = window.innerWidth > 768
|
||||
}
|
||||
})
|
||||
|
||||
return { menuVisible, hideMenu, handleMenuOutside, header }
|
||||
},
|
||||
const handleMenuOutside = (event) => {
|
||||
const btn = header.value.$refs.menuBtn
|
||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||
return // 如果是菜单按钮的点击,不处理关闭
|
||||
}
|
||||
|
||||
if (isMobile.value) {
|
||||
menuVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToNewPost = () => {
|
||||
navigateTo('/new-post', { replace: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="~/assets/global.css"></style>
|
||||
<style>
|
||||
/* 页面过渡效果 */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.4s;
|
||||
}
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -103,6 +118,24 @@ export default {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-new-post-icon {
|
||||
background-color: var(--new-post-icon-color);
|
||||
color: white;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 20px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
backdrop-filter: var(--blur-5);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content,
|
||||
.content.menu-open {
|
||||
|
||||
143
frontend_nuxt/assets/fonts.css
Normal file
143
frontend_nuxt/assets/fonts.css
Normal file
@@ -0,0 +1,143 @@
|
||||
/* Maple Mono - Thin 100 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Thin Italic 100 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraLight 200 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraLight Italic 200 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Light 300 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Light Italic 300 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Regular 400 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Italic 400 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Medium 500 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Medium Italic 500 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - SemiBold 600 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - SemiBold Italic 600 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Bold 700 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Bold Italic 700 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraBold 800 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraBold Italic 800 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -2,27 +2,35 @@
|
||||
--primary-color-hover: rgb(9, 95, 105);
|
||||
--primary-color: rgb(10, 110, 120);
|
||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-height: 60px;
|
||||
--header-background-color: white;
|
||||
--header-border-color: lightgray;
|
||||
--header-text-color: black;
|
||||
--menu-background-color: white;
|
||||
--blur-1: blur(1px);
|
||||
--blur-2: blur(2px);
|
||||
--blur-4: blur(4px);
|
||||
--blur-5: blur(5px);
|
||||
--blur-10: blur(10px);
|
||||
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
|
||||
--app-menu-background-color: white;
|
||||
--background-color: white;
|
||||
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
|
||||
--background-color-blur: var(--background-color);
|
||||
--background-color-blur: rgba(255, 255, 255, 0.57);
|
||||
--menu-border-color: lightgray;
|
||||
--normal-border-color: lightgray;
|
||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||
--menu-text-color: black;
|
||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||
--normal-background-color: rgb(241, 241, 241);
|
||||
/* --normal-background-color: rgb(241, 241, 241); */
|
||||
--normal-background-color: white;
|
||||
--lottery-background-color: rgb(241, 241, 241);
|
||||
--code-highlight-background-color: rgb(241, 241, 241);
|
||||
--login-background-color: rgb(248, 248, 248);
|
||||
--login-background-color-hover: #e0e0e0;
|
||||
--text-color: black;
|
||||
--blockquote-text-color: #6a737d;
|
||||
--menu-width: 200px;
|
||||
--page-max-width: 1200px;
|
||||
--page-max-width: 1400px;
|
||||
--page-max-width-mobile: 900px;
|
||||
--article-info-background-color: #f0f0f0;
|
||||
--activity-card-background-color: #fafafa;
|
||||
@@ -33,8 +41,9 @@
|
||||
--header-border-color: #555;
|
||||
--primary-color: rgb(17, 182, 197);
|
||||
--primary-color-hover: rgb(13, 137, 151);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-text-color: white;
|
||||
--menu-background-color: #333;
|
||||
--app-menu-background-color: #333;
|
||||
--background-color: #333;
|
||||
/* --background-color-blur: #333333a4; */
|
||||
--background-color-blur: var(--background-color);
|
||||
@@ -42,8 +51,10 @@
|
||||
--normal-border-color: #555;
|
||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||
--menu-text-color: white;
|
||||
--normal-background-color: #000000;
|
||||
/* --normal-background-color: #000000; */
|
||||
--normal-background-color: #333;
|
||||
--lottery-background-color: #4e4e4e;
|
||||
--code-highlight-background-color: #262b35;
|
||||
--login-background-color: #575757;
|
||||
--login-background-color-hover: #717171;
|
||||
--text-color: #eee;
|
||||
@@ -52,6 +63,15 @@
|
||||
--activity-card-background-color: #585858;
|
||||
}
|
||||
|
||||
:root[data-frosted='off'] {
|
||||
--blur-1: none;
|
||||
--blur-2: none;
|
||||
--blur-4: none;
|
||||
--blur-5: none;
|
||||
--blur-10: none;
|
||||
--background-color-blur: var(--background-color);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -131,13 +151,43 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
background-color: var(--normal-background-color);
|
||||
display: flex;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers {
|
||||
counter-reset: line-number 0;
|
||||
width: 2em;
|
||||
font-size: 13px;
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Maple Mono', monospace;
|
||||
margin: 1em 0;
|
||||
color: #888;
|
||||
border-right: 1px solid #888;
|
||||
box-sizing: border-box;
|
||||
padding-right: 0.5em;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers .line-number::before {
|
||||
content: counter(line-number);
|
||||
counter-increment: line-number;
|
||||
}
|
||||
|
||||
.info-content-text code {
|
||||
font-family: 'Maple Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: no-wrap;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.copy-code-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -156,20 +206,13 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.info-content-text code {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--normal-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.about-content a,
|
||||
.info-content-text a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about-content a:hover,
|
||||
.info-content-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -267,7 +310,7 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
line-height: 1.1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
@@ -276,6 +319,29 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Transition API */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
[data-theme='dark']::view-transition-old(root) {
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
[data-theme='dark']::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* NProgress styles */
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
|
||||
@@ -41,8 +41,8 @@ export default {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: var(--blur-2);
|
||||
-webkit-backdrop-filter: var(--blur-2);
|
||||
}
|
||||
.popup-content {
|
||||
position: relative;
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
export default {
|
||||
@@ -312,7 +312,7 @@ export default {
|
||||
border: none;
|
||||
outline: none;
|
||||
margin-left: 5px;
|
||||
background-color: var(--menu-background-color);
|
||||
background-color: var(--app-menu-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ export default {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--menu-background-color);
|
||||
background-color: var(--app-menu-background-color);
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,22 +3,24 @@
|
||||
<div class="dropdown-trigger" @click="toggle">
|
||||
<slot name="trigger"></slot>
|
||||
</div>
|
||||
<div v-if="visible" class="dropdown-menu-container">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="dropdown-item"
|
||||
:style="{ color: item.color || 'inherit' }"
|
||||
@click="handle(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
<Transition name="dropdown-menu">
|
||||
<div v-if="visible" class="dropdown-menu-container">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="dropdown-item"
|
||||
:style="{ color: item.color || 'inherit' }"
|
||||
@click="handle(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
export default {
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
@@ -61,17 +63,28 @@ export default {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-menu-enter-active,
|
||||
.dropdown-menu-leave-active {
|
||||
transition: all 0.4s;
|
||||
}
|
||||
.dropdown-menu-enter-from,
|
||||
.dropdown-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: var(--menu-background-color);
|
||||
background-color: var(--app-menu-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -82,7 +95,9 @@ export default {
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</button>
|
||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||
</div>
|
||||
<NuxtLink class="logo-container" :to="`/`">
|
||||
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
|
||||
<img
|
||||
alt="OpenIsle"
|
||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||
@@ -20,11 +20,22 @@
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="isLogin" class="header-content-right">
|
||||
<div class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||
|
||||
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
</div>
|
||||
|
||||
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||
<div class="new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||
@@ -32,14 +43,11 @@
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div v-else class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
<div v-if="!isLogin" class="auth-btns">
|
||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||
</div>
|
||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
|
||||
@@ -52,10 +60,13 @@
|
||||
import { ClientOnly } from '#components'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import ToolTip from '~/components/ToolTip.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
|
||||
const props = defineProps({
|
||||
showMenuBtn: {
|
||||
type: Boolean,
|
||||
@@ -113,12 +124,33 @@ const goToLogout = () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
|
||||
const goToNewPost = () => {
|
||||
navigateTo('/new-post', { replace: false })
|
||||
}
|
||||
|
||||
const refrechData = async () => {
|
||||
await fetchUnreadCount()
|
||||
window.dispatchEvent(new Event('refresh-home'))
|
||||
}
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
{ text: '设置', onClick: goToSettings },
|
||||
{ text: '个人主页', onClick: goToProfile },
|
||||
{ text: '退出', onClick: goToLogout },
|
||||
])
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const updateAvatar = async () => {
|
||||
if (authState.loggedIn) {
|
||||
@@ -146,25 +178,17 @@ onMounted(async () => {
|
||||
await updateUnread()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
if (userMenu.value) userMenu.value.close()
|
||||
showSearch.value = false
|
||||
},
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--header-height);
|
||||
background-color: var(--background-color-blur);
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: var(--blur-10);
|
||||
color: var(--header-text-color);
|
||||
border-bottom: 1px solid var(--header-border-color);
|
||||
}
|
||||
@@ -183,11 +207,10 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: var(--page-max-width);
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.header-content-left {
|
||||
@@ -197,6 +220,14 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.header-content-right {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.auth-btns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -278,7 +309,13 @@ onMounted(async () => {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
.search-icon,
|
||||
.theme-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-post-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const goLogin = () => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: blur(4px);
|
||||
backdrop-filter: var(--blur-4);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,168 +1,195 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<nav v-if="visible" class="menu">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
<div class="menu-content">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="authState.loggedIn"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/points"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-coins"></i>
|
||||
<span class="menu-item-text">
|
||||
积分商城
|
||||
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||
<img
|
||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||
:src="t.smallIcon || t.icon"
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||
<img
|
||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||
:src="t.smallIcon || t.icon"
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
<!-- 解决动态样式的水合错误 -->
|
||||
<ClientOnly v-if="!isMobile">
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</nav>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { authState } from '~/utils/auth'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
visible: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['item-click'])
|
||||
|
||||
const categoryOpen = ref(true)
|
||||
const tagOpen = ref(true)
|
||||
const isLoadingCategory = ref(false)
|
||||
const isLoadingTag = ref(false)
|
||||
const categoryData = ref([])
|
||||
const tagData = ref([])
|
||||
const myPoint = ref(null)
|
||||
|
||||
const fetchCategoryData = async () => {
|
||||
isLoadingCategory.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||
const data = await res.json()
|
||||
categoryData.value = data
|
||||
isLoadingCategory.value = false
|
||||
}
|
||||
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||
const {
|
||||
data: categoryData,
|
||||
pending: isLoadingCategory,
|
||||
error: categoryError,
|
||||
} = await useAsyncData(
|
||||
// 稳定 key:避免 hydration 期误判
|
||||
'menu:categories',
|
||||
() => $fetch(`${API_BASE_URL}/api/categories`),
|
||||
{
|
||||
server: true, // SSR 预取
|
||||
default: () => [], // 初始默认值,减少空判断
|
||||
// 5 分钟内复用缓存,避免路由往返重复请求
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
)
|
||||
|
||||
const fetchTagData = async () => {
|
||||
isLoadingTag.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
|
||||
const data = await res.json()
|
||||
tagData.value = data
|
||||
isLoadingTag.value = false
|
||||
}
|
||||
const {
|
||||
data: tagData,
|
||||
pending: isLoadingTag,
|
||||
error: tagError,
|
||||
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
|
||||
server: true,
|
||||
default: () => [],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
@@ -178,6 +205,15 @@ const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||
|
||||
const loadPoint = async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await fetchCurrentUser()
|
||||
myPoint.value = user ? user.point : null
|
||||
} else {
|
||||
myPoint.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const updateCount = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
@@ -187,8 +223,15 @@ const updateCount = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await updateCount()
|
||||
watch(() => authState.loggedIn, updateCount)
|
||||
await Promise.all([updateCount(), loadPoint()])
|
||||
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
() => {
|
||||
updateCount()
|
||||
loadPoint()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const handleItemClick = () => {
|
||||
@@ -211,28 +254,35 @@ const gotoTag = (t) => {
|
||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
handleItemClick()
|
||||
}
|
||||
|
||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
width: 200px;
|
||||
background-color: var(--menu-background-color);
|
||||
width: 220px;
|
||||
background-color: var(--app-menu-background-color);
|
||||
height: calc(100vh - 20px - var(--header-height));
|
||||
border-right: 1px solid var(--menu-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.menu-item-container {
|
||||
.menu-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 10px 0 10px;
|
||||
}
|
||||
|
||||
/* .menu-item-container { */
|
||||
/**/
|
||||
/* } */
|
||||
|
||||
.menu-item {
|
||||
padding: 4px 10px;
|
||||
text-decoration: none;
|
||||
@@ -271,6 +321,12 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.point-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
margin-right: 10px;
|
||||
opacity: 0.5;
|
||||
@@ -278,10 +334,8 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
position: fixed;
|
||||
position: relation;
|
||||
height: 30px;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -369,6 +423,10 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
background-color: var(--background-color-blur);
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition:
|
||||
|
||||
@@ -40,30 +40,22 @@
|
||||
兑换
|
||||
</div>
|
||||
<div v-else class="redeem-button disabled">兑换</div>
|
||||
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput
|
||||
textarea=""
|
||||
rows="5"
|
||||
v-model="contact"
|
||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||
/>
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeDialog"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import ProgressBar from '~/components/ProgressBar.vue'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -185,56 +177,6 @@ const submitRedeem = async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.redeem-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redeem-submit-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.redeem-submit-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.redeem-cancel-button {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-cancel-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.user-level-text {
|
||||
opacity: 0.8;
|
||||
font-size: 12px;
|
||||
@@ -247,9 +189,5 @@ const submitRedeem = async () => {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
103
frontend_nuxt/components/RedeemPopup.vue
Normal file
103
frontend_nuxt/components/RedeemPopup.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<BasePopup :visible="visible" @close="onClose">
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput
|
||||
textarea
|
||||
rows="5"
|
||||
v-model="innerContact"
|
||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||
/>
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submit" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="onClose">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'close'])
|
||||
|
||||
const innerContact = ref(props.modelValue)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
innerContact.value = v
|
||||
},
|
||||
)
|
||||
watch(innerContact, (v) => emit('update:modelValue', v))
|
||||
|
||||
const submit = () => {
|
||||
emit('submit')
|
||||
}
|
||||
const onClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.redeem-dialog-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.redeem-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redeem-submit-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.redeem-submit-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.redeem-cancel-button {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-cancel-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.redeem-dialog-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -37,10 +37,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -110,6 +110,10 @@ watch(selected, (val) => {
|
||||
selected.value = null
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
toggle,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -131,7 +135,7 @@ watch(selected, (val) => {
|
||||
}
|
||||
|
||||
.text-input {
|
||||
background-color: var(--menu-background-color);
|
||||
background-color: var(--app-menu-background-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
547
frontend_nuxt/components/ToolTip.vue
Normal file
547
frontend_nuxt/components/ToolTip.vue
Normal file
@@ -0,0 +1,547 @@
|
||||
<template>
|
||||
<div class="tooltip-wrapper" ref="wrapperRef">
|
||||
<!-- 触发器 -->
|
||||
<div
|
||||
class="tooltip-trigger"
|
||||
:tabindex="focusable ? 0 : -1"
|
||||
:aria-describedby="visible ? ariaId : undefined"
|
||||
@mouseenter="onTriggerMouseEnter"
|
||||
@mouseleave="onTriggerMouseLeave"
|
||||
@click="onTriggerClick"
|
||||
@focus="onTriggerFocus"
|
||||
@blur="onTriggerBlur"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 提示内容(Teleport 到 body) -->
|
||||
<Teleport to="body" v-if="mounted">
|
||||
<Transition name="tooltip-fade">
|
||||
<div
|
||||
v-show="visible"
|
||||
:id="ariaId"
|
||||
ref="tooltipRef"
|
||||
class="tooltip-content"
|
||||
:class="[
|
||||
`tooltip-${currentPlacement}`,
|
||||
dark ? 'tooltip-dark' : 'tooltip-light',
|
||||
props.trigger === 'hover' ? 'tooltip-noninteractive' : '',
|
||||
]"
|
||||
:style="tooltipInlineStyle"
|
||||
role="tooltip"
|
||||
>
|
||||
<div class="tooltip-inner">
|
||||
<slot name="content">
|
||||
{{ content }}
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- 箭头 -->
|
||||
<div
|
||||
class="tooltip-arrow"
|
||||
:class="`tooltip-arrow-${currentPlacement}`"
|
||||
:style="arrowStyle"
|
||||
></div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
watch,
|
||||
defineProps,
|
||||
defineEmits,
|
||||
defineOptions,
|
||||
useId,
|
||||
} from 'vue'
|
||||
|
||||
defineOptions({ name: 'Tooltip' })
|
||||
|
||||
type Trigger = 'hover' | 'click' | 'focus' | 'manual'
|
||||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
const props = defineProps({
|
||||
content: { type: String, default: '' },
|
||||
trigger: {
|
||||
type: String as () => Trigger,
|
||||
default: 'hover',
|
||||
validator: (v: string) => ['hover', 'click', 'focus', 'manual'].includes(v),
|
||||
},
|
||||
placement: {
|
||||
type: String as () => Placement,
|
||||
default: 'top',
|
||||
validator: (v: string) => ['top', 'bottom', 'left', 'right'].includes(v),
|
||||
},
|
||||
dark: { type: Boolean, default: false },
|
||||
delay: { type: Number, default: 100 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
focusable: { type: Boolean, default: true },
|
||||
offset: { type: Number, default: 8 },
|
||||
maxWidth: { type: [String, Number], default: '200px' },
|
||||
/** 隐藏延时(毫秒),hover 离开后等待一点点以防抖 */
|
||||
hideDelay: { type: Number, default: 80 },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'show'): void
|
||||
(e: 'hide'): void
|
||||
}>()
|
||||
|
||||
const wrapperRef = ref<HTMLElement | null>(null)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
const visible = ref(false)
|
||||
const currentPlacement = ref<Placement>(props.placement)
|
||||
const ariaId = ref(`tooltip-${useId()}`)
|
||||
const mounted = ref(false)
|
||||
|
||||
let showTimer: number | null = null
|
||||
let hideTimer: number | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
let rafId: number | null = null
|
||||
|
||||
const maxWidthValue = computed(() => {
|
||||
return typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
|
||||
})
|
||||
|
||||
const tooltipTransform = ref('translate3d(-9999px, -9999px, 0)')
|
||||
|
||||
const tooltipInlineStyle = computed(() => ({
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
zIndex: 2000,
|
||||
maxWidth: maxWidthValue.value,
|
||||
transform: tooltipTransform.value,
|
||||
}))
|
||||
|
||||
const arrowStyle = ref<Record<string, string>>({})
|
||||
|
||||
const clearTimers = () => {
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const show = async () => {
|
||||
if (props.disabled) return
|
||||
clearTimers()
|
||||
showTimer = window.setTimeout(async () => {
|
||||
visible.value = true
|
||||
emit('show')
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}, props.delay)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
clearTimers()
|
||||
hideTimer = window.setTimeout(() => {
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
}, props.hideDelay)
|
||||
}
|
||||
|
||||
const showImmediately = async () => {
|
||||
if (props.disabled) return
|
||||
clearTimers()
|
||||
visible.value = true
|
||||
emit('show')
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}
|
||||
const hideImmediately = () => {
|
||||
clearTimers()
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
// 触发器事件
|
||||
const onTriggerMouseEnter = () => {
|
||||
if (props.trigger === 'hover') show()
|
||||
}
|
||||
const onTriggerMouseLeave = () => {
|
||||
// 关键修改:hover 模式下,离开触发区即开始隐藏计时,不再保持可交互
|
||||
if (props.trigger === 'hover') hide()
|
||||
}
|
||||
const onTriggerClick = () => {
|
||||
if (props.trigger !== 'click') return
|
||||
visible.value ? hideImmediately() : showImmediately()
|
||||
}
|
||||
const onTriggerFocus = () => {
|
||||
if (props.trigger === 'focus') showImmediately()
|
||||
}
|
||||
const onTriggerBlur = () => {
|
||||
if (props.trigger === 'focus') hideImmediately()
|
||||
}
|
||||
|
||||
// 点击外部关闭(只对 click 模式)
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (props.trigger !== 'click') return
|
||||
const w = wrapperRef.value
|
||||
const t = tooltipRef.value
|
||||
const target = e.target as Node
|
||||
if (w && !w.contains(target) && t && !t.contains(target)) {
|
||||
hideImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
// 定位算法
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
function computeBasePosition(
|
||||
placement: Placement,
|
||||
triggerRect: DOMRect,
|
||||
tooltipRect: DOMRect,
|
||||
offset: number,
|
||||
) {
|
||||
const centerX = triggerRect.left + triggerRect.width / 2
|
||||
const centerY = triggerRect.top + triggerRect.height / 2
|
||||
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
return {
|
||||
top: triggerRect.top - tooltipRect.height - offset,
|
||||
left: centerX - tooltipRect.width / 2,
|
||||
}
|
||||
case 'bottom':
|
||||
return {
|
||||
top: triggerRect.bottom + offset,
|
||||
left: centerX - tooltipRect.width / 2,
|
||||
}
|
||||
case 'left':
|
||||
return {
|
||||
top: centerY - tooltipRect.height / 2,
|
||||
left: triggerRect.left - tooltipRect.width - offset,
|
||||
}
|
||||
case 'right':
|
||||
return {
|
||||
top: centerY - tooltipRect.height / 2,
|
||||
left: triggerRect.right + offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function positionWithSmartFlip(
|
||||
preferred: Placement,
|
||||
triggerRect: DOMRect,
|
||||
tooltipRect: DOMRect,
|
||||
offset: number,
|
||||
) {
|
||||
const padding = 8
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
let placement: Placement = preferred
|
||||
let { top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!
|
||||
|
||||
const outTop = top < padding
|
||||
const outBottom = top + tooltipRect.height > vh - padding
|
||||
const outLeft = left < padding
|
||||
const outRight = left + tooltipRect.width > vw - padding
|
||||
|
||||
if (
|
||||
placement === 'top' &&
|
||||
outTop &&
|
||||
triggerRect.bottom + offset + tooltipRect.height <= vh - padding
|
||||
) {
|
||||
placement = 'bottom'
|
||||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||
} else if (
|
||||
placement === 'bottom' &&
|
||||
outBottom &&
|
||||
triggerRect.top - offset - tooltipRect.height >= padding
|
||||
) {
|
||||
placement = 'top'
|
||||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||
} else if (
|
||||
placement === 'left' &&
|
||||
outLeft &&
|
||||
triggerRect.right + offset + tooltipRect.width <= vw - padding
|
||||
) {
|
||||
placement = 'right'
|
||||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||
} else if (
|
||||
placement === 'right' &&
|
||||
outRight &&
|
||||
triggerRect.left - offset - tooltipRect.width >= padding
|
||||
) {
|
||||
placement = 'left'
|
||||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||
}
|
||||
|
||||
top = clamp(top, padding, vh - tooltipRect.height - padding)
|
||||
left = clamp(left, padding, vw - tooltipRect.width - padding)
|
||||
|
||||
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
||||
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
||||
const arrowLeft = clamp(triggerCenterX - left, 10, tooltipRect.width - 10)
|
||||
const arrowTop = clamp(triggerCenterY - top, 10, tooltipRect.height - 10)
|
||||
|
||||
return { placement, top, left, arrowLeft, arrowTop }
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!wrapperRef.value || !tooltipRef.value || !visible.value) return
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const triggerEl = wrapperRef.value!.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||
const tooltipEl = tooltipRef.value!
|
||||
if (!triggerEl) return
|
||||
|
||||
const triggerRect = triggerEl.getBoundingClientRect()
|
||||
const tooltipRect = tooltipEl.getBoundingClientRect()
|
||||
const { placement, top, left, arrowLeft, arrowTop } = positionWithSmartFlip(
|
||||
props.placement,
|
||||
triggerRect,
|
||||
tooltipRect,
|
||||
props.offset,
|
||||
)
|
||||
|
||||
currentPlacement.value = placement
|
||||
tooltipTransform.value = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`
|
||||
if (placement === 'top' || placement === 'bottom') {
|
||||
arrowStyle.value = { '--arrow-left': `${Math.round(arrowLeft)}px` } as any
|
||||
} else {
|
||||
arrowStyle.value = { '--arrow-top': `${Math.round(arrowTop)}px` } as any
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onEnvChanged = () => {
|
||||
if (visible.value) updatePosition()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(v) => {
|
||||
if (v && visible.value) hideImmediately()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => props.placement,
|
||||
() => {
|
||||
if (visible.value) nextTick(updatePosition)
|
||||
},
|
||||
)
|
||||
watch(visible, (v) => {
|
||||
if (!mounted.value) return
|
||||
if (v) {
|
||||
if ('ResizeObserver' in window && !ro) {
|
||||
ro = new ResizeObserver(() => updatePosition())
|
||||
if (tooltipRef.value) ro.observe(tooltipRef.value)
|
||||
const triggerEl = wrapperRef.value?.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||
if (triggerEl) ro.observe(triggerEl)
|
||||
}
|
||||
updatePosition()
|
||||
} else {
|
||||
if (ro) {
|
||||
ro.disconnect()
|
||||
ro = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
mounted.value = true
|
||||
window.addEventListener('resize', onEnvChanged, { passive: true })
|
||||
window.addEventListener('scroll', onEnvChanged, { passive: true, capture: true })
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (ro) {
|
||||
ro.disconnect()
|
||||
ro = null
|
||||
}
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
window.removeEventListener('resize', onEnvChanged)
|
||||
window.removeEventListener('scroll', onEnvChanged, true)
|
||||
})
|
||||
|
||||
// 暴露给父组件(manual 可用)
|
||||
defineExpose({ show: showImmediately, hide: hideImmediately, updatePosition })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.tooltip-trigger {
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
}
|
||||
.tooltip-trigger:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
will-change: transform;
|
||||
pointer-events: auto; /* 默认允许交互(click/focus 模式) */
|
||||
}
|
||||
.tooltip-noninteractive {
|
||||
/* hover 模式下禁用指针事件,避免移入浮层导致保持显示 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tooltip-inner {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 主题 */
|
||||
.tooltip-light .tooltip-inner {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
border-color: var(--normal-border-color);
|
||||
}
|
||||
.tooltip-dark .tooltip-inner {
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 箭头(用 CSS 变量控制偏移) */
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* 顶部 */
|
||||
.tooltip-top .tooltip-arrow-top {
|
||||
bottom: -6px;
|
||||
left: var(--arrow-left, 50%);
|
||||
transform: translateX(-50%);
|
||||
border-width: 6px 6px 0 6px;
|
||||
}
|
||||
.tooltip-light.tooltip-top .tooltip-arrow-top {
|
||||
border-color: var(--normal-border-color) transparent transparent transparent;
|
||||
}
|
||||
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: -6px;
|
||||
border-width: 6px 6px 0 6px;
|
||||
border-style: solid;
|
||||
border-color: var(--background-color) transparent transparent transparent;
|
||||
}
|
||||
.tooltip-dark.tooltip-top .tooltip-arrow-top {
|
||||
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.tooltip-bottom .tooltip-arrow-bottom {
|
||||
top: -6px;
|
||||
left: var(--arrow-left, 50%);
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 6px 6px 6px;
|
||||
}
|
||||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
|
||||
border-color: transparent transparent var(--normal-border-color) transparent;
|
||||
}
|
||||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: -6px;
|
||||
border-width: 0 6px 6px 6px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent var(--background-color) transparent;
|
||||
}
|
||||
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
|
||||
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
|
||||
}
|
||||
|
||||
/* 左侧 */
|
||||
.tooltip-left .tooltip-arrow-left {
|
||||
right: -6px;
|
||||
top: var(--arrow-top, 50%);
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 0 6px 6px;
|
||||
}
|
||||
.tooltip-light.tooltip-left .tooltip-arrow-left {
|
||||
border-color: transparent transparent transparent var(--normal-border-color);
|
||||
}
|
||||
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -7px;
|
||||
border-width: 6px 0 6px 6px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent transparent var(--background-color);
|
||||
}
|
||||
.tooltip-dark.tooltip-left .tooltip-arrow-left {
|
||||
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
/* 右侧 */
|
||||
.tooltip-right .tooltip-arrow-right {
|
||||
left: -6px;
|
||||
top: var(--arrow-top, 50%);
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 6px 6px 0;
|
||||
}
|
||||
.tooltip-light.tooltip-right .tooltip-arrow-right {
|
||||
border-color: transparent var(--normal-border-color) transparent transparent;
|
||||
}
|
||||
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 1px;
|
||||
border-width: 6px 6px 6px 0;
|
||||
border-style: solid;
|
||||
border-color: transparent var(--background-color) transparent transparent;
|
||||
}
|
||||
.tooltip-dark.tooltip-right .tooltip-arrow-right {
|
||||
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.tooltip-fade-enter-active,
|
||||
.tooltip-fade-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
.tooltip-fade-enter-from,
|
||||
.tooltip-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 4px, 0) scale(0.98);
|
||||
}
|
||||
|
||||
/* 响应式微调 */
|
||||
@media (max-width: 768px) {
|
||||
.tooltip-inner {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1 +1,5 @@
|
||||
export { toast } from './composables/useToast'
|
||||
|
||||
export const API_DOMAIN = 'https://www.open-isle.com'
|
||||
export const API_PORT = ''
|
||||
export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN
|
||||
|
||||
@@ -12,9 +12,10 @@ export default defineNuxtConfig({
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
},
|
||||
},
|
||||
// Ensure Vditor styles load before our overrides in global.css
|
||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
||||
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
app: {
|
||||
pageTransition: { name: 'page', mode: 'out-in' },
|
||||
head: {
|
||||
script: [
|
||||
{
|
||||
@@ -26,7 +27,31 @@ export default defineNuxtConfig({
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
} catch (e) {}
|
||||
|
||||
|
||||
let themeColor = '#fff';
|
||||
let themeStatus = 'default';
|
||||
if (theme === 'dark') {
|
||||
themeColor = '#333';
|
||||
themeStatus = 'black-translucent';
|
||||
} else {
|
||||
themeColor = '#ffffff';
|
||||
themeStatus = 'default';
|
||||
}
|
||||
|
||||
const androidMeta = document.createElement('meta');
|
||||
androidMeta.name = 'theme-color';
|
||||
androidMeta.content = themeColor;
|
||||
|
||||
const iosMeta = document.createElement('meta');
|
||||
iosMeta.name = 'apple-mobile-web-app-status-bar-style';
|
||||
iosMeta.content = themeStatus;
|
||||
|
||||
document.head.appendChild(androidMeta);
|
||||
document.head.appendChild(iosMeta);
|
||||
} catch (e) {
|
||||
console.warn('Theme initialization failed:', e);
|
||||
}
|
||||
})();
|
||||
`,
|
||||
},
|
||||
@@ -60,4 +85,27 @@ export default defineNuxtConfig({
|
||||
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
build: {
|
||||
// increase warning limit and split large libraries into separate chunks
|
||||
chunkSizeWarningLimit: 1024,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('vditor')) {
|
||||
return 'vditor'
|
||||
}
|
||||
if (id.includes('echarts')) {
|
||||
return 'echarts'
|
||||
}
|
||||
if (id.includes('highlight.js')) {
|
||||
return 'highlight'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
4950
frontend_nuxt/package-lock.json
generated
4950
frontend_nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
<div class="forgot-page">
|
||||
<div class="forgot-content">
|
||||
<div class="forgot-title">找回密码</div>
|
||||
|
||||
<div v-if="step === 0" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
@@ -19,6 +20,10 @@
|
||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||
<div class="primary-button disabled" v-else>提交中...</div>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,6 +31,8 @@
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -39,6 +46,7 @@ const passwordError = ref('')
|
||||
const isSending = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const isResetting = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.email) {
|
||||
@@ -137,6 +145,21 @@ const resetPassword = async () => {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.forgot-content .hint-message {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--blockquote-text-color);
|
||||
}
|
||||
|
||||
.hint-message i {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div v-if="!isMobile" class="search-container">
|
||||
<div class="search-title">一切可能,从此刻启航</div>
|
||||
<div class="search-subtitle">
|
||||
愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。
|
||||
</div>
|
||||
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
||||
<SearchDropdown />
|
||||
</div>
|
||||
|
||||
@@ -116,7 +113,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
@@ -150,9 +147,17 @@ const categoryOptions = ref([])
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopicCookie = useCookie('homeTab')
|
||||
const selectedTopic = ref(
|
||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
||||
selectedTopicCookie.value
|
||||
? selectedTopicCookie.value
|
||||
: route.query.view === 'ranking'
|
||||
? '排行榜'
|
||||
: route.query.view === 'latest'
|
||||
? '最新'
|
||||
: '最新回复',
|
||||
)
|
||||
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||
const articles = ref([])
|
||||
const page = ref(0)
|
||||
const pageSize = 10
|
||||
@@ -178,6 +183,11 @@ onMounted(() => {
|
||||
const { category, tags } = route.query
|
||||
if (category) selectedCategorySet(category)
|
||||
if (tags) selectedTagsSet(tags)
|
||||
|
||||
const saved = localStorage.getItem('homeTab')
|
||||
if (saved) {
|
||||
selectedTopic.value = saved
|
||||
}
|
||||
})
|
||||
|
||||
/** 路由变更时同步筛选 **/
|
||||
@@ -195,7 +205,7 @@ watch(
|
||||
const loadOptions = async () => {
|
||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/`)
|
||||
if (res.ok) categoryOptions.value = [await res.json()]
|
||||
} catch {}
|
||||
}
|
||||
@@ -265,7 +275,7 @@ const {
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
pinned: !!p.pinnedAt,
|
||||
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||
type: p.type,
|
||||
}))
|
||||
},
|
||||
@@ -308,7 +318,7 @@ const fetchNextPage = async () => {
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
pinned: !!p.pinnedAt,
|
||||
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||
type: p.type,
|
||||
}))
|
||||
articles.value.push(...mapped)
|
||||
@@ -343,9 +353,13 @@ watch(
|
||||
watch([selectedCategory, selectedTags], () => {
|
||||
loadOptions()
|
||||
})
|
||||
watch(selectedTopic, () => {
|
||||
watch(selectedTopic, (val) => {
|
||||
// 仅当需要额外选项时加载
|
||||
loadOptions()
|
||||
selectedTopicCookie.value = val
|
||||
if (process.client) {
|
||||
localStorage.setItem('homeTab', val)
|
||||
}
|
||||
})
|
||||
|
||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||
@@ -354,6 +368,8 @@ if (import.meta.server) {
|
||||
}
|
||||
onMounted(() => {
|
||||
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||
|
||||
window.addEventListener('refresh-home', refreshFirst)
|
||||
})
|
||||
|
||||
/** 其他工具函数 **/
|
||||
@@ -371,8 +387,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-top: 100px;
|
||||
padding: 20px;
|
||||
margin-top: 32px;
|
||||
padding: 20px 20px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -384,10 +400,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.search-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -422,6 +434,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.topic-item-container {
|
||||
@@ -541,6 +554,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
display: -webkit-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
>找回密码</a
|
||||
>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,6 +263,11 @@ const loginWithTwitter = () => {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.hint-message {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-page {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -130,6 +130,12 @@
|
||||
申请进行奶茶兑换,联系方式是:{{ item.content }}
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POINT_REDEEM' && !item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<span class="notif-user">{{ item.fromUser.username }} </span>
|
||||
申请积分兑换,联系方式是:{{ item.content }}
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
||||
@@ -505,21 +511,26 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import NotificationContainer from '~/components/NotificationContainer.vue'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import {
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
isLoadingMessage,
|
||||
markRead,
|
||||
notifications,
|
||||
markAllRead,
|
||||
} from '~/utils/notification'
|
||||
import TimeManager from '~/utils/time'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const route = useRoute()
|
||||
const notifications = ref([])
|
||||
const isLoadingMessage = ref(false)
|
||||
const selectedTab = ref(
|
||||
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
||||
)
|
||||
@@ -528,234 +539,6 @@ const filteredNotifications = computed(() =>
|
||||
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
|
||||
)
|
||||
|
||||
const markRead = async (id) => {
|
||||
if (!id) return
|
||||
const n = notifications.value.find((n) => n.id === id)
|
||||
if (!n || n.read) return
|
||||
n.read = true
|
||||
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
||||
const ok = await markNotificationsRead([id])
|
||||
if (!ok) {
|
||||
n.read = false
|
||||
notificationState.unreadCount++
|
||||
} else {
|
||||
fetchUnreadCount()
|
||||
}
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
// 除了 REGISTER_REQUEST 类型消息
|
||||
const idsToMark = notifications.value
|
||||
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
|
||||
.map((n) => n.id)
|
||||
if (idsToMark.length === 0) return
|
||||
notifications.value.forEach((n) => {
|
||||
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
||||
})
|
||||
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
|
||||
const ok = await markNotificationsRead(idsToMark)
|
||||
if (!ok) {
|
||||
notifications.value.forEach((n) => {
|
||||
if (idsToMark.includes(n.id)) n.read = false
|
||||
})
|
||||
await fetchUnreadCount()
|
||||
return
|
||||
}
|
||||
fetchUnreadCount()
|
||||
if (authState.role === 'ADMIN') {
|
||||
toast.success('已读所有消息(注册请求除外)')
|
||||
} else {
|
||||
toast.success('已读所有消息')
|
||||
}
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
POST_VIEWED: 'fas fa-eye',
|
||||
COMMENT_REPLY: 'fas fa-reply',
|
||||
POST_REVIEWED: 'fas fa-shield-alt',
|
||||
POST_REVIEW_REQUEST: 'fas fa-gavel',
|
||||
POST_UPDATED: 'fas fa-comment-dots',
|
||||
USER_ACTIVITY: 'fas fa-user',
|
||||
FOLLOWED_POST: 'fas fa-feather-alt',
|
||||
USER_FOLLOWED: 'fas fa-user-plus',
|
||||
USER_UNFOLLOWED: 'fas fa-user-minus',
|
||||
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
}
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
isLoadingMessage.value = true
|
||||
notifications.value = []
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
isLoadingMessage.value = false
|
||||
if (!res.ok) {
|
||||
toast.error('获取通知失败')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
for (const n of data) {
|
||||
if (n.type === 'COMMENT_REPLY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REACTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
emoji: reactionEmojiMap[n.reactionType],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_VIEWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_WIN') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_DRAW') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_UPDATED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_ACTIVITY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'MENTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'FOLLOWED_POST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REGISTER_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {},
|
||||
})
|
||||
} else {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPrefs = async () => {
|
||||
notificationPrefs.value = await fetchNotificationPreferences()
|
||||
}
|
||||
@@ -833,6 +616,8 @@ const formatType = (t) => {
|
||||
return '有人申请注册'
|
||||
case 'ACTIVITY_REDEEM':
|
||||
return '有人申请兑换奶茶'
|
||||
case 'POINT_REDEEM':
|
||||
return '有人申请积分兑换'
|
||||
case 'LOTTERY_WIN':
|
||||
return '抽奖中奖了'
|
||||
case 'LOTTERY_DRAW':
|
||||
@@ -842,7 +627,7 @@ const formatType = (t) => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onActivated(() => {
|
||||
fetchNotifications()
|
||||
fetchPrefs()
|
||||
})
|
||||
@@ -859,6 +644,8 @@ onMounted(() => {
|
||||
.message-page {
|
||||
background-color: var(--background-color);
|
||||
overflow-x: hidden;
|
||||
height: calc(100vh - var(--header-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-page-header {
|
||||
@@ -870,6 +657,7 @@ onMounted(() => {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.message-page-header-right {
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
|
||||
229
frontend_nuxt/pages/points.vue
Normal file
229
frontend_nuxt/pages/points.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="point-mall-page">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="loading-points-container" v-if="isLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span class="point-value">{{
|
||||
point
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
</div>
|
||||
<div
|
||||
class="goods-item-button"
|
||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||
@click="openRedeem(good)"
|
||||
>
|
||||
兑换
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const point = ref(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const pointRules = [
|
||||
'发帖:每天前两次,每次 30 积分',
|
||||
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
|
||||
'帖子被点赞:每次 10 积分',
|
||||
'评论被点赞:每次 10 积分',
|
||||
]
|
||||
|
||||
const goods = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const contact = ref('')
|
||||
const loading = ref(false)
|
||||
const selectedGood = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
if (authState.loggedIn) {
|
||||
const user = await fetchCurrentUser()
|
||||
point.value = user ? user.point : null
|
||||
}
|
||||
await loadGoods()
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
const loadGoods = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
|
||||
if (res.ok) {
|
||||
goods.value = await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
const openRedeem = (good) => {
|
||||
if (!authState.loggedIn || point.value === null || point.value < good.cost) {
|
||||
toast.error('积分不足')
|
||||
return
|
||||
}
|
||||
selectedGood.value = good
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const closeRedeem = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
const submitRedeem = async () => {
|
||||
if (!selectedGood.value || !contact.value) return
|
||||
loading.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-goods/redeem`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ goodId: selectedGood.value.id, contact: contact.value }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
point.value = data.point
|
||||
toast.success('兑换成功!')
|
||||
dialogVisible.value = false
|
||||
contact.value = ''
|
||||
} else {
|
||||
toast.error('兑换失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.point-mall-page {
|
||||
padding-left: 20px;
|
||||
max-width: var(--page-max-width);
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-points-container {
|
||||
margin-top: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.point-info {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.point-value {
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.coin-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.rules,
|
||||
.goods {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.goods {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.goods-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-item-name {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.goods-item-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.goods-item-cost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.goods-item-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
width: calc(100% - 40px);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.goods-item-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.goods-item-button.disabled,
|
||||
.goods-item-button.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -232,7 +232,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
import { useRoute } from 'vue-router'
|
||||
import CommentItem from '~/components/CommentItem.vue'
|
||||
@@ -268,7 +268,6 @@ const postReactions = ref([])
|
||||
const comments = ref([])
|
||||
const status = ref('PUBLISHED')
|
||||
const pinnedAt = ref(null)
|
||||
const isWaitingFetchingPost = ref(false)
|
||||
const isWaitingPostingComment = ref(false)
|
||||
const postTime = ref('')
|
||||
const postItems = ref([])
|
||||
@@ -392,7 +391,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
|
||||
avatar: c.author.avatar,
|
||||
text: c.content,
|
||||
reactions: c.reactions || [],
|
||||
pinned: !!c.pinnedAt,
|
||||
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||
openReplies: level === 0,
|
||||
src: c.author.avatar,
|
||||
@@ -455,38 +454,41 @@ const onCommentDeleted = (id) => {
|
||||
fetchComments()
|
||||
}
|
||||
|
||||
const fetchPost = async () => {
|
||||
try {
|
||||
isWaitingFetchingPost.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
isWaitingFetchingPost.value = false
|
||||
if (!res.ok) {
|
||||
if (res.status === 404 && process.client) {
|
||||
router.replace('/404')
|
||||
}
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
postContent.value = data.content
|
||||
author.value = data.author
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
await nextTick()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
const {
|
||||
data: postData,
|
||||
pending: pendingPost,
|
||||
error: postError,
|
||||
refresh: refreshPost,
|
||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||
server: true,
|
||||
lazy: false,
|
||||
})
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
|
||||
// 同步到现有的响应式字段
|
||||
watchEffect(() => {
|
||||
const data = postData.value
|
||||
if (!data) return
|
||||
postContent.value = data.content
|
||||
author.value = data.author
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
})
|
||||
|
||||
// 404 客户端跳转
|
||||
// if (postError.value?.statusCode === 404 && process.client) {
|
||||
// router.replace('/404')
|
||||
// }
|
||||
|
||||
const totalPosts = computed(() => comments.value.length + 1)
|
||||
const lastReplyTime = computed(() =>
|
||||
@@ -607,6 +609,7 @@ const approvePost = async () => {
|
||||
if (res.ok) {
|
||||
status.value = 'PUBLISHED'
|
||||
toast.success('已通过审核')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -620,8 +623,8 @@ const pinPost = async () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
pinnedAt.value = new Date().toISOString()
|
||||
toast.success('已置顶')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -635,8 +638,8 @@ const unpinPost = async () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
pinnedAt.value = null
|
||||
toast.success('已取消置顶')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -674,6 +677,7 @@ const rejectPost = async () => {
|
||||
if (res.ok) {
|
||||
status.value = 'REJECTED'
|
||||
toast.success('已驳回')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -709,7 +713,7 @@ const joinLottery = async () => {
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('已参与抽奖')
|
||||
await fetchPost()
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -780,9 +784,8 @@ onMounted(async () => {
|
||||
window.addEventListener('scroll', updateCurrentIndex)
|
||||
jumpToHashComment()
|
||||
})
|
||||
|
||||
await fetchPost()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
@@ -864,6 +867,7 @@ await fetchPost()
|
||||
direction: ltr;
|
||||
height: 300px;
|
||||
width: 2px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
||||
</div>
|
||||
<div class="form-row switch-row">
|
||||
<div class="setting-title">毛玻璃效果</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="frosted" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||
<h3>管理员设置</h3>
|
||||
@@ -65,12 +72,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const username = ref('')
|
||||
@@ -87,6 +95,7 @@ const aiFormatLimit = ref(3)
|
||||
const registerMode = ref('DIRECT')
|
||||
const isLoadingPage = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const frosted = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoadingPage.value = true
|
||||
@@ -105,6 +114,7 @@ onMounted(async () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
isLoadingPage.value = false
|
||||
frosted.value = frostedState.enabled
|
||||
})
|
||||
|
||||
const onAvatarChange = (e) => {
|
||||
@@ -118,6 +128,7 @@ const onAvatarChange = (e) => {
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
watch(frosted, (val) => setFrosted(val))
|
||||
const onCropped = ({ file, url }) => {
|
||||
avatarFile.value = file
|
||||
avatar.value = url
|
||||
@@ -300,6 +311,58 @@ const save = async () => {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const reason = ref('')
|
||||
const error = ref('')
|
||||
const isWaitingForRegister = ref(false)
|
||||
const token = ref('')
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
token.value = route.query.token || ''
|
||||
@@ -50,8 +51,8 @@ const submit = async () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: this.token,
|
||||
reason: this.reason,
|
||||
token: token.value,
|
||||
reason: reason.value,
|
||||
}),
|
||||
})
|
||||
isWaitingForRegister.value = false
|
||||
|
||||
@@ -35,10 +35,12 @@
|
||||
/>
|
||||
<div class="profile-level-target">
|
||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||
<i
|
||||
class="fas fa-info-circle profile-exp-info"
|
||||
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||
></i>
|
||||
<ToolTip
|
||||
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||
placement="bottom"
|
||||
>
|
||||
<i class="fas fa-info-circle profile-exp-info"></i>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,67 +206,89 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||
<div class="timeline-tabs">
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||
@click="timelineFilter = 'all'"
|
||||
>
|
||||
全部
|
||||
</div>
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||
@click="timelineFilter = 'articles'"
|
||||
>
|
||||
文章
|
||||
</div>
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||
@click="timelineFilter = 'comments'"
|
||||
>
|
||||
评论和回复
|
||||
</div>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-if="timelineItems.length === 0"
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
<BaseTimeline :items="timelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下评论了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下对
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</router-link>
|
||||
回复了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下评论了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下对
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</router-link>
|
||||
回复了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||
@@ -324,6 +348,15 @@ const hotPosts = ref([])
|
||||
const hotReplies = ref([])
|
||||
const hotTags = ref([])
|
||||
const timelineItems = ref([])
|
||||
const timelineFilter = ref('all')
|
||||
const filteredTimelineItems = computed(() => {
|
||||
if (timelineFilter.value === 'articles') {
|
||||
return timelineItems.value.filter((item) => item.type === 'post')
|
||||
} else if (timelineFilter.value === 'comments') {
|
||||
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||
}
|
||||
return timelineItems.value
|
||||
})
|
||||
const followers = ref([])
|
||||
const followings = ref([])
|
||||
const medals = ref([])
|
||||
@@ -654,12 +687,6 @@ watch(selectedTab, async (val) => {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.profile-exp-info {
|
||||
margin-left: 4px;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -700,6 +727,7 @@ watch(selectedTab, async (val) => {
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
scrollbar-width: none;
|
||||
overflow-x: auto;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.profile-tabs-item {
|
||||
@@ -777,8 +805,24 @@ watch(selectedTab, async (val) => {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.profile-timeline {
|
||||
padding: 20px;
|
||||
.timeline-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.timeline-tab-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-tab-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
|
||||
6
frontend_nuxt/plugins/frosted.client.ts
Normal file
6
frontend_nuxt/plugins/frosted.client.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
import { initFrosted } from '~/utils/frosted'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
initFrosted()
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// 覆盖默认行为:收到 manifest 更新时,不立刻在路由切换里刷新
|
||||
nuxtApp.hooks.hook('app:manifest:update', () => {
|
||||
// todo 选择:弹个提示,让用户点击刷新;或延迟到页面隐藏时再刷新
|
||||
// 例如:document.addEventListener('visibilitychange', () => { if (document.hidden) location.reload() })
|
||||
})
|
||||
})
|
||||
BIN
frontend_nuxt/public/fonts/maple-mono-100-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-100-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-100-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-100-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-200-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-200-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-200-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-200-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-300-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-300-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-300-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-300-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-400-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-400-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-400-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-400-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-500-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-500-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-500-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-500-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-600-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-600-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-600-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-600-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-700-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-700-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-700-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-700-normal.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-800-italic.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-800-italic.woff2
Normal file
Binary file not shown.
BIN
frontend_nuxt/public/fonts/maple-mono-800-normal.woff2
Normal file
BIN
frontend_nuxt/public/fonts/maple-mono-800-normal.woff2
Normal file
Binary file not shown.
26
frontend_nuxt/utils/frosted.js
Normal file
26
frontend_nuxt/utils/frosted.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const FROSTED_KEY = 'frosted-glass'
|
||||
|
||||
export const frostedState = reactive({
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
function apply() {
|
||||
if (!import.meta.client) return
|
||||
document.documentElement.dataset.frosted = frostedState.enabled ? 'on' : 'off'
|
||||
}
|
||||
|
||||
export function initFrosted() {
|
||||
if (!import.meta.client) return
|
||||
const saved = localStorage.getItem(FROSTED_KEY)
|
||||
frostedState.enabled = saved !== 'false'
|
||||
apply()
|
||||
}
|
||||
|
||||
export function setFrosted(enabled) {
|
||||
if (!import.meta.client) return
|
||||
frostedState.enabled = enabled
|
||||
localStorage.setItem(FROSTED_KEY, enabled ? 'true' : 'false')
|
||||
apply()
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export async function googleGetIdToken() {
|
||||
export function googleAuthorize() {
|
||||
const config = useRuntimeConfig()
|
||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
return
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github.css'
|
||||
if (typeof window !== 'undefined') {
|
||||
const theme =
|
||||
document.documentElement.dataset.theme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
if (theme === 'dark') {
|
||||
import('highlight.js/styles/atom-one-dark.css')
|
||||
} else {
|
||||
import('highlight.js/styles/atom-one-light.css')
|
||||
}
|
||||
}
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { toast } from '../main'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
@@ -86,7 +95,11 @@ const md = new MarkdownIt({
|
||||
} else {
|
||||
code = hljs.highlightAuto(str).value
|
||||
}
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
|
||||
const lineNumbers = code
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(() => `<div class="line-number"></div>`)
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { getToken } from './auth'
|
||||
import { reactive } from 'vue'
|
||||
import { navigateTo, useRuntimeConfig } from 'nuxt/app'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { toast } from '~/composables/useToast'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
|
||||
export const notificationState = reactive({
|
||||
unreadCount: 0,
|
||||
})
|
||||
|
||||
const iconMap = {
|
||||
POST_VIEWED: 'fas fa-eye',
|
||||
COMMENT_REPLY: 'fas fa-reply',
|
||||
POST_REVIEWED: 'fas fa-shield-alt',
|
||||
POST_REVIEW_REQUEST: 'fas fa-gavel',
|
||||
POST_UPDATED: 'fas fa-comment-dots',
|
||||
USER_ACTIVITY: 'fas fa-user',
|
||||
FOLLOWED_POST: 'fas fa-feather-alt',
|
||||
USER_FOLLOWED: 'fas fa-user-plus',
|
||||
USER_UNFOLLOWED: 'fas fa-user-minus',
|
||||
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
POINT_REDEEM: 'fas fa-gift',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -87,3 +110,235 @@ export async function updateNotificationPreference(type, enabled) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理信息的高阶函数
|
||||
* @returns
|
||||
*/
|
||||
function createFetchNotifications() {
|
||||
const notifications = ref([])
|
||||
const isLoadingMessage = ref(false)
|
||||
const fetchNotifications = async () => {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
if (isLoadingMessage && notifications && markRead) {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
isLoadingMessage.value = true
|
||||
notifications.value = []
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
isLoadingMessage.value = false
|
||||
if (!res.ok) {
|
||||
toast.error('获取通知失败')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
for (const n of data) {
|
||||
if (n.type === 'COMMENT_REPLY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REACTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
emoji: reactionEmojiMap[n.reactionType],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_VIEWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_WIN') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_DRAW') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_UPDATED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_ACTIVITY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'MENTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'FOLLOWED_POST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REGISTER_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {},
|
||||
})
|
||||
} else {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id) => {
|
||||
if (!id) return
|
||||
const n = notifications.value.find((n) => n.id === id)
|
||||
if (!n || n.read) return
|
||||
n.read = true
|
||||
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
||||
const ok = await markNotificationsRead([id])
|
||||
if (!ok) {
|
||||
n.read = false
|
||||
notificationState.unreadCount++
|
||||
} else {
|
||||
fetchUnreadCount()
|
||||
}
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
// 除了 REGISTER_REQUEST 类型消息
|
||||
const idsToMark = notifications.value
|
||||
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
|
||||
.map((n) => n.id)
|
||||
if (idsToMark.length === 0) return
|
||||
notifications.value.forEach((n) => {
|
||||
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
||||
})
|
||||
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
|
||||
const ok = await markNotificationsRead(idsToMark)
|
||||
if (!ok) {
|
||||
notifications.value.forEach((n) => {
|
||||
if (idsToMark.includes(n.id)) n.read = false
|
||||
})
|
||||
await fetchUnreadCount()
|
||||
return
|
||||
}
|
||||
fetchUnreadCount()
|
||||
if (authState.role === 'ADMIN') {
|
||||
toast.success('已读所有消息(注册请求除外)')
|
||||
} else {
|
||||
toast.success('已读所有消息')
|
||||
}
|
||||
}
|
||||
return {
|
||||
fetchNotifications,
|
||||
markRead,
|
||||
notifications,
|
||||
isLoadingMessage,
|
||||
markRead,
|
||||
markAllRead,
|
||||
}
|
||||
}
|
||||
|
||||
export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } =
|
||||
createFetchNotifications()
|
||||
|
||||
@@ -14,19 +14,46 @@ export const themeState = reactive({
|
||||
})
|
||||
|
||||
function apply(mode) {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
const root = document.documentElement
|
||||
if (mode === ThemeMode.SYSTEM) {
|
||||
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
let newMode =
|
||||
mode === ThemeMode.SYSTEM
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: mode
|
||||
if (root.dataset.theme === newMode) return
|
||||
root.dataset.theme = newMode
|
||||
|
||||
// 更新 meta 标签
|
||||
const androidMeta = document.querySelector('meta[name="theme-color"]')
|
||||
const iosMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]')
|
||||
const themeColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background-color')
|
||||
.trim()
|
||||
const themeStatus = newMode === 'dark' ? 'black-translucent' : 'default'
|
||||
|
||||
if (androidMeta) {
|
||||
androidMeta.content = themeColor
|
||||
} else {
|
||||
root.dataset.theme = mode
|
||||
const newAndroidMeta = document.createElement('meta')
|
||||
newAndroidMeta.name = 'theme-color'
|
||||
newAndroidMeta.content = themeColor
|
||||
document.head.appendChild(newAndroidMeta)
|
||||
}
|
||||
|
||||
if (iosMeta) {
|
||||
iosMeta.content = themeStatus
|
||||
} else {
|
||||
const newIosMeta = document.createElement('meta')
|
||||
newIosMeta.name = 'apple-mobile-web-app-status-bar-style'
|
||||
newIosMeta.content = themeStatus
|
||||
document.head.appendChild(newIosMeta)
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
const saved = localStorage.getItem(THEME_KEY)
|
||||
if (saved && Object.values(ThemeMode).includes(saved)) {
|
||||
themeState.mode = saved
|
||||
@@ -35,15 +62,117 @@ export function initTheme() {
|
||||
}
|
||||
|
||||
export function setTheme(mode) {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (!Object.values(ThemeMode).includes(mode)) return
|
||||
themeState.mode = mode
|
||||
localStorage.setItem(THEME_KEY, mode)
|
||||
apply(mode)
|
||||
}
|
||||
|
||||
export function cycleTheme() {
|
||||
if (!process.client) return
|
||||
function getCircle(event) {
|
||||
if (!import.meta.client) return undefined
|
||||
|
||||
let x, y
|
||||
if (event.touches?.length) {
|
||||
x = event.touches[0].clientX
|
||||
y = event.touches[0].clientY
|
||||
} else if (event.changedTouches?.length) {
|
||||
x = event.changedTouches[0].clientX
|
||||
y = event.changedTouches[0].clientY
|
||||
} else {
|
||||
x = event.clientX
|
||||
y = event.clientY
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
radius: Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)),
|
||||
}
|
||||
}
|
||||
|
||||
function withViewTransition(event, applyFn, direction = true) {
|
||||
if (typeof document !== 'undefined' && document.startViewTransition) {
|
||||
const transition = document.startViewTransition(async () => {
|
||||
applyFn()
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
transition.ready
|
||||
.then(() => {
|
||||
const { x, y, radius } = getCircle(event)
|
||||
|
||||
const clipPath = [`circle(0 at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`]
|
||||
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: direction ? clipPath : [...clipPath].reverse(),
|
||||
},
|
||||
{
|
||||
duration: 400,
|
||||
easing: 'ease-in-out',
|
||||
pseudoElement: direction
|
||||
? '::view-transition-new(root)'
|
||||
: '::view-transition-old(root)',
|
||||
},
|
||||
)
|
||||
})
|
||||
.catch(console.warn)
|
||||
} else {
|
||||
fallbackThemeTransition(applyFn)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackThemeTransition(applyFn) {
|
||||
if (!import.meta.client) return
|
||||
|
||||
const root = document.documentElement
|
||||
const computedStyle = getComputedStyle(root)
|
||||
|
||||
// 获取当前背景色用于过渡
|
||||
const currentBg = computedStyle.getPropertyValue('--background-color').trim()
|
||||
|
||||
// 创建过渡元素
|
||||
const transitionElement = document.createElement('div')
|
||||
transitionElement.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: ${currentBg};
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
backdrop-filter: var(--blur-1);
|
||||
`
|
||||
document.body.appendChild(transitionElement)
|
||||
|
||||
// 使用 Web Animations API 实现淡出动画
|
||||
const animation = transitionElement.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||
duration: 300,
|
||||
easing: 'ease-out',
|
||||
})
|
||||
|
||||
// 应用主题变更
|
||||
applyFn()
|
||||
|
||||
// 动画完成后清理
|
||||
animation.finished
|
||||
.then(() => {
|
||||
document.body.removeChild(transitionElement)
|
||||
})
|
||||
.catch(() => {
|
||||
// 降级处理
|
||||
document.body.removeChild(transitionElement)
|
||||
})
|
||||
}
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function cycleTheme(event) {
|
||||
if (!import.meta.client) return
|
||||
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
||||
const index = modes.indexOf(themeState.mode)
|
||||
const next = modes[(index + 1) % modes.length]
|
||||
@@ -54,10 +183,31 @@ export function cycleTheme() {
|
||||
} else {
|
||||
toast.success('🌙 已经切换到暗色主题')
|
||||
}
|
||||
setTheme(next)
|
||||
// 获取当前真实主题
|
||||
const currentTheme = themeState.mode === ThemeMode.SYSTEM ? getSystemTheme() : themeState.mode
|
||||
|
||||
// 获取新主题的真实表现
|
||||
const nextTheme = next === ThemeMode.SYSTEM ? getSystemTheme() : next
|
||||
|
||||
// 如果新旧主题相同,不用过渡动画
|
||||
if (currentTheme === nextTheme) {
|
||||
setTheme(next)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算新主题是否是暗色
|
||||
const newThemeIsDark = nextTheme === 'dark'
|
||||
|
||||
withViewTransition(
|
||||
event,
|
||||
() => {
|
||||
setTheme(next)
|
||||
},
|
||||
!newThemeIsDark,
|
||||
)
|
||||
}
|
||||
|
||||
if (process.client && window.matchMedia) {
|
||||
if (import.meta.client && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (themeState.mode === ThemeMode.SYSTEM) {
|
||||
apply(ThemeMode.SYSTEM)
|
||||
|
||||
Reference in New Issue
Block a user