mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-17 19:24:23 +00:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 22693bfdd9 | |||
| 0058f20b1e | |||
| 304d941d68 | |||
| 3dbcd2ac4d | |||
| 2efe4e733a | |||
| 08239a16b8 | |||
| cb49dc9b73 | |||
| 43d4c9be43 | |||
| 1dc13698ad | |||
| d58432dcd9 | |||
| e7ff73c7f9 | |||
| 4ee9532d5f | |||
| 80c3fd8ea2 | |||
| 7e277d06d5 | |||
| d2b68119bd | |||
| f7b0d7edd5 | |||
| cdea1ab911 | |||
| ada6bfb5cf | |||
| 928dbd73b5 | |||
| 8c1a7afc6e | |||
| 87453f7198 | |||
| 48e3593ef9 | |||
| 655e8f2a65 | |||
| 7a0afedc7c | |||
| 902fce5174 | |||
| 0034839e8d | |||
| 148fd36fd1 | |||
| 06cd663eaf | |||
| 65cc3ee58b | |||
| 6965fcfb7f | |||
| 40520c30ec | |||
| 5d7ca3d29a | |||
| 9209ebea4c | |||
| 47a9ce5843 |
@@ -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
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
name: CI & CD
|
name: CI & CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -13,22 +13,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# - uses: actions/setup-java@v4
|
|
||||||
# with:
|
|
||||||
# java-version: '17'
|
|
||||||
# distribution: 'temurin'
|
|
||||||
|
|
||||||
# - run: mvn -B clean package -DskipTests
|
|
||||||
|
|
||||||
# - uses: actions/setup-node@v4
|
|
||||||
# with:
|
|
||||||
# node-version: '20'
|
|
||||||
|
|
||||||
# - run: |
|
|
||||||
# cd open-isle-cli
|
|
||||||
# npm ci
|
|
||||||
# npm run build
|
|
||||||
|
|
||||||
- name: Deploy to Server
|
- name: Deploy to Server
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
||||||
|
|
||||||
## 🚀 部署
|
## 🚧 开发
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
|
|
||||||
@@ -20,9 +20,26 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
|||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
|
|
||||||
1. `cd open-isle-cli`
|
1. 进入前端目录
|
||||||
2. 执行 `npm install`
|
```bash
|
||||||
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
cd frontend_nuxt
|
||||||
|
```
|
||||||
|
2. 安装依赖
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. 启动开发服务
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
生产版本使用如下命令编译:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
会在 `.output` 目录生成文件,配合线上网站方式部署
|
||||||
|
|
||||||
## ✨ 项目特点
|
## ✨ 项目特点
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class SecurityConfig {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AccessDeniedHandler customAccessDeniedHandler;
|
private final AccessDeniedHandler customAccessDeniedHandler;
|
||||||
private final UserVisitService userVisitService;
|
private final UserVisitService userVisitService;
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -75,14 +75,16 @@ public class SecurityConfig {
|
|||||||
cfg.setAllowedOrigins(List.of(
|
cfg.setAllowedOrigins(List.of(
|
||||||
"http://127.0.0.1:8080",
|
"http://127.0.0.1:8080",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
"http://127.0.0.1",
|
"http://127.0.0.1",
|
||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
"http://localhost",
|
"http://localhost",
|
||||||
"http://30.211.97.238:3000",
|
"http://30.211.97.238:3000",
|
||||||
"http://30.211.97.238",
|
"http://30.211.97.238",
|
||||||
"http://192.168.7.70",
|
"http://192.168.7.98",
|
||||||
"http://192.168.7.70:8080",
|
"http://192.168.7.98:3000",
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
websiteUrl.replace("://www.", "://")
|
websiteUrl.replace("://www.", "://")
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class AdminUserController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final NotificationRepository notificationRepository;
|
private final NotificationRepository notificationRepository;
|
||||||
private final EmailSender emailSender;
|
private final EmailSender emailSender;
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@PostMapping("/{id}/approve")
|
@PostMapping("/{id}/approve")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -44,8 +45,11 @@ public class CategoryController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<CategoryDto> list() {
|
public List<CategoryDto> list() {
|
||||||
return categoryService.listCategories().stream()
|
List<Category> all = categoryService.listCategories();
|
||||||
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId())))
|
List<Long> ids = all.stream().map(Category::getId).toList();
|
||||||
|
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
|
||||||
|
return all.stream()
|
||||||
|
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
|
||||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import java.util.List;
|
|||||||
public class SitemapController {
|
public class SitemapController {
|
||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -62,8 +63,11 @@ public class TagController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||||
List<TagDto> dtos = tagService.searchTags(keyword).stream()
|
List<Tag> tags = tagService.searchTags(keyword);
|
||||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||||
|
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||||
|
List<TagDto> dtos = tags.stream()
|
||||||
|
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class Notification {
|
|||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, length = 50)
|
||||||
private NotificationType type;
|
private NotificationType type;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ public enum NotificationType {
|
|||||||
ACTIVITY_REDEEM,
|
ACTIVITY_REDEEM,
|
||||||
/** You won a lottery post */
|
/** You won a lottery post */
|
||||||
LOTTERY_WIN,
|
LOTTERY_WIN,
|
||||||
|
/** Your lottery post was drawn */
|
||||||
|
LOTTERY_DRAW,
|
||||||
/** You were mentioned in a post or comment */
|
/** You were mentioned in a post or comment */
|
||||||
MENTION
|
MENTION
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
|
|
||||||
long countByCategory_Id(Long categoryId);
|
long countByCategory_Id(Long categoryId);
|
||||||
|
|
||||||
|
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
|
||||||
|
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
|
||||||
|
|
||||||
long countDistinctByTags_Id(Long tagId);
|
long countDistinctByTags_Id(Long tagId);
|
||||||
|
|
||||||
|
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
|
||||||
|
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
|
||||||
|
|
||||||
long countByAuthor_Id(Long userId);
|
long countByAuthor_Id(Long userId);
|
||||||
|
|
||||||
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class NotificationService {
|
|||||||
private final ReactionRepository reactionRepository;
|
private final ReactionRepository reactionRepository;
|
||||||
private final Executor notificationExecutor;
|
private final Executor notificationExecutor;
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||||
|
|||||||
@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
|
|||||||
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.*;
|
||||||
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
@@ -254,6 +253,13 @@ public class PostService {
|
|||||||
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
||||||
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||||
}
|
}
|
||||||
|
if (lp.getAuthor() != null) {
|
||||||
|
if (lp.getAuthor().getEmail() != null) {
|
||||||
|
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
|
||||||
|
}
|
||||||
|
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
|
||||||
|
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,10 +566,31 @@ public class PostService {
|
|||||||
return postRepository.countByCategory_Id(categoryId);
|
return postRepository.countByCategory_Id(categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
|
||||||
|
Map<Long, Long> result = new HashMap<>();
|
||||||
|
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
|
||||||
|
dbResult.forEach(r -> {
|
||||||
|
result.put(((Long)r[0]), ((Long)r[1]));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public long countPostsByTag(Long tagId) {
|
public long countPostsByTag(Long tagId) {
|
||||||
return postRepository.countDistinctByTags_Id(tagId);
|
return postRepository.countDistinctByTags_Id(tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
|
||||||
|
Map<Long, Long> result = new HashMap<>();
|
||||||
|
if (CollectionUtils.isEmpty(tagIds)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
var dbResult = postRepository.countPostsByTagIds(tagIds);
|
||||||
|
dbResult.forEach(r -> {
|
||||||
|
result.put(((Long)r[0]), ((Long)r[1]));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
||||||
return posts.stream()
|
return posts.stream()
|
||||||
.sorted(java.util.Comparator
|
.sorted(java.util.Comparator
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class ReactionService {
|
|||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final EmailSender emailSender;
|
private final EmailSender emailSender;
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -93,4 +93,50 @@ class PostServiceTest {
|
|||||||
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
||||||
null, null, null, null, null, null));
|
null, null, null, null, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finalizeLotteryNotifiesAuthor() {
|
||||||
|
PostRepository postRepo = mock(PostRepository.class);
|
||||||
|
UserRepository userRepo = mock(UserRepository.class);
|
||||||
|
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||||
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
|
CommentService commentService = mock(CommentService.class);
|
||||||
|
CommentRepository commentRepo = mock(CommentRepository.class);
|
||||||
|
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
||||||
|
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
|
||||||
|
NotificationRepository notificationRepo = mock(NotificationRepository.class);
|
||||||
|
PostReadService postReadService = mock(PostReadService.class);
|
||||||
|
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||||
|
TaskScheduler taskScheduler = mock(TaskScheduler.class);
|
||||||
|
EmailSender emailSender = mock(EmailSender.class);
|
||||||
|
ApplicationContext context = mock(ApplicationContext.class);
|
||||||
|
|
||||||
|
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||||
|
notifService, subService, commentService, commentRepo,
|
||||||
|
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||||
|
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
|
||||||
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
|
|
||||||
|
User author = new User();
|
||||||
|
author.setId(1L);
|
||||||
|
User winner = new User();
|
||||||
|
winner.setId(2L);
|
||||||
|
|
||||||
|
LotteryPost lp = new LotteryPost();
|
||||||
|
lp.setId(1L);
|
||||||
|
lp.setAuthor(author);
|
||||||
|
lp.setTitle("L");
|
||||||
|
lp.setPrizeCount(1);
|
||||||
|
lp.getParticipants().add(winner);
|
||||||
|
|
||||||
|
when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp));
|
||||||
|
|
||||||
|
service.finalizeLottery(1L);
|
||||||
|
|
||||||
|
verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull());
|
||||||
|
verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
; 本地部署后端
|
||||||
|
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||||
|
; 预发环境后端
|
||||||
|
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||||
|
; 生产环境后端
|
||||||
|
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||||
|
|
||||||
|
; 预发环境
|
||||||
|
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||||
|
; 正式环境/生产环境
|
||||||
|
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||||
|
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||||
|
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||||
|
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||||
|
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||||
@@ -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
|
||||||
@@ -2,3 +2,4 @@ node_modules
|
|||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
.output
|
.output
|
||||||
|
.env
|
||||||
+71
-38
@@ -15,62 +15,77 @@
|
|||||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||||
<NuxtPage keepalive />
|
<NuxtPage keepalive />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GlobalPopups />
|
<GlobalPopups />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import HeaderComponent from '~/components/HeaderComponent.vue'
|
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||||
import MenuComponent from '~/components/MenuComponent.vue'
|
import MenuComponent from '~/components/MenuComponent.vue'
|
||||||
|
|
||||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||||
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
|
||||||
export default {
|
const isMobile = useIsMobile()
|
||||||
name: 'App',
|
const menuVisible = ref(!isMobile.value)
|
||||||
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
|
||||||
setup() {
|
|
||||||
const 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 header = useTemplateRef('header')
|
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||||
|
|
||||||
onMounted(() => {
|
const hideMenu = computed(() => {
|
||||||
if (typeof window !== 'undefined') {
|
return [
|
||||||
menuVisible.value = window.innerWidth > 768
|
'/login',
|
||||||
}
|
'/signup',
|
||||||
})
|
'/404',
|
||||||
|
'/signup-reason',
|
||||||
|
'/github-callback',
|
||||||
|
'/twitter-callback',
|
||||||
|
'/discord-callback',
|
||||||
|
'/forgot-password',
|
||||||
|
'/google-callback',
|
||||||
|
].includes(useRoute().path)
|
||||||
|
})
|
||||||
|
|
||||||
const handleMenuOutside = (event) => {
|
const header = useTemplateRef('header')
|
||||||
const btn = header.value.$refs.menuBtn
|
|
||||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
|
||||||
return // 如果是菜单按钮的点击,不处理关闭
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile.value) {
|
onMounted(() => {
|
||||||
menuVisible.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style src="~/assets/global.css"></style>
|
<style src="~/assets/global.css"></style>
|
||||||
<style>
|
<style>
|
||||||
|
/* 页面过渡效果 */
|
||||||
|
.page-enter-active,
|
||||||
|
.page-leave-active {
|
||||||
|
transition: all 0.4s;
|
||||||
|
}
|
||||||
|
.page-enter-from,
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -103,6 +118,24 @@ export default {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-new-post-icon {
|
||||||
|
background-color: var(--new-post-icon-color);
|
||||||
|
color: white;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 40px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
backdrop-filter: var(--blur-5);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.content,
|
.content,
|
||||||
.content.menu-open {
|
.content.menu-open {
|
||||||
|
|||||||
@@ -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-hover: rgb(9, 95, 105);
|
||||||
--primary-color: rgb(10, 110, 120);
|
--primary-color: rgb(10, 110, 120);
|
||||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||||
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-height: 60px;
|
--header-height: 60px;
|
||||||
--header-background-color: white;
|
--header-background-color: white;
|
||||||
--header-border-color: lightgray;
|
--header-border-color: lightgray;
|
||||||
--header-text-color: black;
|
--header-text-color: black;
|
||||||
--menu-background-color: white;
|
--blur-1: blur(1px);
|
||||||
|
--blur-2: blur(2px);
|
||||||
|
--blur-4: blur(4px);
|
||||||
|
--blur-5: blur(5px);
|
||||||
|
--blur-10: blur(10px);
|
||||||
|
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
|
||||||
|
--app-menu-background-color: white;
|
||||||
--background-color: white;
|
--background-color: white;
|
||||||
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
|
--background-color-blur: rgba(255, 255, 255, 0.57);
|
||||||
--background-color-blur: var(--background-color);
|
|
||||||
--menu-border-color: lightgray;
|
--menu-border-color: lightgray;
|
||||||
--normal-border-color: lightgray;
|
--normal-border-color: lightgray;
|
||||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||||
--menu-text-color: black;
|
--menu-text-color: black;
|
||||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||||
--normal-background-color: rgb(241, 241, 241);
|
/* --normal-background-color: rgb(241, 241, 241); */
|
||||||
|
--normal-background-color: white;
|
||||||
--lottery-background-color: rgb(241, 241, 241);
|
--lottery-background-color: rgb(241, 241, 241);
|
||||||
|
--code-highlight-background-color: rgb(241, 241, 241);
|
||||||
--login-background-color: rgb(248, 248, 248);
|
--login-background-color: rgb(248, 248, 248);
|
||||||
--login-background-color-hover: #e0e0e0;
|
--login-background-color-hover: #e0e0e0;
|
||||||
--text-color: black;
|
--text-color: black;
|
||||||
--blockquote-text-color: #6a737d;
|
--blockquote-text-color: #6a737d;
|
||||||
--menu-width: 200px;
|
--menu-width: 200px;
|
||||||
--page-max-width: 1200px;
|
--page-max-width: 1400px;
|
||||||
--page-max-width-mobile: 900px;
|
--page-max-width-mobile: 900px;
|
||||||
--article-info-background-color: #f0f0f0;
|
--article-info-background-color: #f0f0f0;
|
||||||
--activity-card-background-color: #fafafa;
|
--activity-card-background-color: #fafafa;
|
||||||
@@ -33,8 +41,9 @@
|
|||||||
--header-border-color: #555;
|
--header-border-color: #555;
|
||||||
--primary-color: rgb(17, 182, 197);
|
--primary-color: rgb(17, 182, 197);
|
||||||
--primary-color-hover: rgb(13, 137, 151);
|
--primary-color-hover: rgb(13, 137, 151);
|
||||||
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-text-color: white;
|
--header-text-color: white;
|
||||||
--menu-background-color: #333;
|
--app-menu-background-color: #333;
|
||||||
--background-color: #333;
|
--background-color: #333;
|
||||||
/* --background-color-blur: #333333a4; */
|
/* --background-color-blur: #333333a4; */
|
||||||
--background-color-blur: var(--background-color);
|
--background-color-blur: var(--background-color);
|
||||||
@@ -42,8 +51,10 @@
|
|||||||
--normal-border-color: #555;
|
--normal-border-color: #555;
|
||||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||||
--menu-text-color: white;
|
--menu-text-color: white;
|
||||||
--normal-background-color: #000000;
|
/* --normal-background-color: #000000; */
|
||||||
|
--normal-background-color: #333;
|
||||||
--lottery-background-color: #4e4e4e;
|
--lottery-background-color: #4e4e4e;
|
||||||
|
--code-highlight-background-color: #262b35;
|
||||||
--login-background-color: #575757;
|
--login-background-color: #575757;
|
||||||
--login-background-color-hover: #717171;
|
--login-background-color-hover: #717171;
|
||||||
--text-color: #eee;
|
--text-color: #eee;
|
||||||
@@ -52,6 +63,15 @@
|
|||||||
--activity-card-background-color: #585858;
|
--activity-card-background-color: #585858;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-frosted='off'] {
|
||||||
|
--blur-1: none;
|
||||||
|
--blur-2: none;
|
||||||
|
--blur-4: none;
|
||||||
|
--blur-5: none;
|
||||||
|
--blur-10: none;
|
||||||
|
--background-color-blur: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -131,13 +151,43 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
background-color: var(--normal-background-color);
|
display: flex;
|
||||||
|
background-color: var(--code-highlight-background-color);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-content-text pre .line-numbers {
|
||||||
|
counter-reset: line-number 0;
|
||||||
|
width: 2em;
|
||||||
|
font-size: 13px;
|
||||||
|
position: sticky;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: 'Maple Mono', monospace;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: #888;
|
||||||
|
border-right: 1px solid #888;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text pre .line-numbers .line-number::before {
|
||||||
|
content: counter(line-number);
|
||||||
|
counter-increment: line-number;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text code {
|
||||||
|
font-family: 'Maple Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: no-wrap;
|
||||||
|
background-color: var(--code-highlight-background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
.copy-code-btn {
|
.copy-code-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
@@ -156,20 +206,13 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text code {
|
.about-content a,
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
background-color: var(--normal-background-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-content-text a {
|
.info-content-text a {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-content a:hover,
|
||||||
.info-content-text a:hover {
|
.info-content-text a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
@@ -267,7 +310,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
line-height: 1.1;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vditor-panel {
|
.vditor-panel {
|
||||||
@@ -276,6 +319,29 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Transition API */
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(root) {
|
||||||
|
z-index: 2147483646;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark']::view-transition-old(root) {
|
||||||
|
z-index: 2147483646;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark']::view-transition-new(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* NProgress styles */
|
/* NProgress styles */
|
||||||
#nprogress {
|
#nprogress {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -37,8 +37,10 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { getToken } from '~/utils/auth'
|
import { getToken } from '~/utils/auth'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
medals: {
|
medals: {
|
||||||
|
|||||||
@@ -11,29 +11,20 @@
|
|||||||
</BasePopup>
|
</BasePopup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BasePopup from '~/components/BasePopup.vue'
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'ActivityPopup',
|
visible: { type: Boolean, default: false },
|
||||||
components: { BasePopup },
|
icon: String,
|
||||||
props: {
|
text: String,
|
||||||
visible: { type: Boolean, default: false },
|
})
|
||||||
icon: String,
|
const emit = defineEmits(['close'])
|
||||||
text: String,
|
const gotoActivity = async () => {
|
||||||
},
|
emit('close')
|
||||||
emits: ['close'],
|
await navigateTo('/activities', { replace: true })
|
||||||
setup(props, { emit }) {
|
|
||||||
const router = useRouter()
|
|
||||||
const gotoActivity = () => {
|
|
||||||
emit('close')
|
|
||||||
router.push('/activities')
|
|
||||||
}
|
|
||||||
const close = () => emit('close')
|
|
||||||
return { gotoActivity, close }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const close = () => emit('close')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -12,25 +12,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
const props = defineProps({
|
||||||
|
category: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
export default {
|
const gotoCategory = async () => {
|
||||||
name: 'ArticleCategory',
|
if (!props.category) return
|
||||||
props: {
|
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
||||||
category: { type: Object, default: null },
|
await navigateTo({ path: '/', query: { category: value } }, { replace: true })
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const router = useRouter()
|
|
||||||
const gotoCategory = () => {
|
|
||||||
if (!props.category) return
|
|
||||||
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
|
||||||
router.push({ path: '/', query: { category: value } }).then(() => {
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { gotoCategory }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -17,24 +17,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
defineProps({
|
||||||
|
tags: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
export default {
|
const gotoTag = async (tag) => {
|
||||||
name: 'ArticleTags',
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
props: {
|
await navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||||
tags: { type: Array, default: () => [] },
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const router = useRouter()
|
|
||||||
const gotoTag = (tag) => {
|
|
||||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
|
||||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { gotoTag }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: var(--blur-2);
|
||||||
-webkit-backdrop-filter: blur(2px);
|
-webkit-backdrop-filter: var(--blur-2);
|
||||||
}
|
}
|
||||||
.popup-content {
|
.popup-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -26,49 +26,43 @@
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'CategorySelect',
|
modelValue: { type: [String, Number], default: '' },
|
||||||
components: { Dropdown },
|
options: { type: Array, default: () => [] },
|
||||||
props: {
|
})
|
||||||
modelValue: { type: [String, Number], default: '' },
|
|
||||||
options: { type: Array, default: () => [] },
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
(val) => {
|
||||||
|
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
)
|
||||||
setup(props, { emit }) {
|
|
||||||
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
|
||||||
|
|
||||||
watch(
|
const fetchCategories = async () => {
|
||||||
() => props.options,
|
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||||
(val) => {
|
if (!res.ok) return []
|
||||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
const data = await res.json()
|
||||||
},
|
return [{ id: '', name: '无分类' }, ...data]
|
||||||
)
|
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
|
||||||
if (!res.ok) return []
|
|
||||||
const data = await res.json()
|
|
||||||
return [{ id: '', name: '无分类' }, ...data]
|
|
||||||
}
|
|
||||||
|
|
||||||
const isImageIcon = (icon) => {
|
|
||||||
if (!icon) return false
|
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v),
|
|
||||||
})
|
|
||||||
|
|
||||||
return { fetchCategories, selected, isImageIcon, providedOptions }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isImageIcon = (icon) => {
|
||||||
|
if (!icon) return false
|
||||||
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -90,11 +90,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||||
import { useRouter } from 'vue-router'
|
import { toast } from '~/main'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||||
import { getMedalTitle } from '~/utils/medal'
|
import { getMedalTitle } from '~/utils/medal'
|
||||||
@@ -102,271 +101,236 @@ import TimeManager from '~/utils/time'
|
|||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import CommentEditor from '~/components/CommentEditor.vue'
|
import CommentEditor from '~/components/CommentEditor.vue'
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
|
||||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const CommentItem = {
|
const props = defineProps({
|
||||||
name: 'CommentItem',
|
comment: {
|
||||||
emits: ['deleted'],
|
type: Object,
|
||||||
props: {
|
required: true,
|
||||||
comment: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
level: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
defaultShowReplies: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
postAuthorId: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
level: {
|
||||||
const router = useRouter()
|
type: Number,
|
||||||
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
default: 0,
|
||||||
watch(
|
|
||||||
() => props.defaultShowReplies,
|
|
||||||
(val) => {
|
|
||||||
showReplies.value = props.level === 0 ? true : val
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const showEditor = ref(false)
|
|
||||||
const editorWrapper = ref(null)
|
|
||||||
const isWaitingForReply = ref(false)
|
|
||||||
const lightboxVisible = ref(false)
|
|
||||||
const lightboxIndex = ref(0)
|
|
||||||
const lightboxImgs = ref([])
|
|
||||||
const loggedIn = computed(() => authState.loggedIn)
|
|
||||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
|
||||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
|
||||||
const toggleReplies = () => {
|
|
||||||
showReplies.value = !showReplies.value
|
|
||||||
}
|
|
||||||
const toggleEditor = () => {
|
|
||||||
showEditor.value = !showEditor.value
|
|
||||||
if (showEditor.value) {
|
|
||||||
setTimeout(() => {
|
|
||||||
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并所有子回复为一个扁平数组
|
|
||||||
const flattenReplies = (list) => {
|
|
||||||
let result = []
|
|
||||||
for (const r of list) {
|
|
||||||
result.push(r)
|
|
||||||
if (r.reply && r.reply.length > 0) {
|
|
||||||
result = result.concat(flattenReplies(r.reply))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const replyList = computed(() => {
|
|
||||||
if (props.level < 1) {
|
|
||||||
return props.comment.reply
|
|
||||||
}
|
|
||||||
|
|
||||||
return flattenReplies(props.comment.reply || [])
|
|
||||||
})
|
|
||||||
|
|
||||||
const isAuthor = computed(() => authState.username === props.comment.userName)
|
|
||||||
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
|
|
||||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
|
||||||
const commentMenuItems = computed(() => {
|
|
||||||
const items = []
|
|
||||||
if (isAuthor.value || isAdmin.value) {
|
|
||||||
items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() })
|
|
||||||
}
|
|
||||||
if (isAdmin.value || isPostAuthor.value) {
|
|
||||||
if (props.comment.pinned) {
|
|
||||||
items.push({ text: '取消置顶', onClick: () => unpinComment() })
|
|
||||||
} else {
|
|
||||||
items.push({ text: '置顶', onClick: () => pinComment() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
})
|
|
||||||
const deleteComment = async () => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.debug('Deleting comment', props.comment.id)
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
console.debug('Delete comment response status', res.status)
|
|
||||||
if (res.ok) {
|
|
||||||
toast.success('已删除')
|
|
||||||
emit('deleted', props.comment.id)
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const pinComment = async () => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const url = isAdmin.value
|
|
||||||
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin`
|
|
||||||
: `${API_BASE_URL}/api/comments/${props.comment.id}/pin`
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
props.comment.pinned = true
|
|
||||||
toast.success('已置顶')
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const unpinComment = async () => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const url = isAdmin.value
|
|
||||||
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin`
|
|
||||||
: `${API_BASE_URL}/api/comments/${props.comment.id}/unpin`
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
props.comment.pinned = false
|
|
||||||
toast.success('已取消置顶')
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const submitReply = async (parentUserName, text, clear) => {
|
|
||||||
if (!text.trim()) return
|
|
||||||
isWaitingForReply.value = true
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
isWaitingForReply.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.debug('Submitting reply', { parentId: props.comment.id, text })
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ content: text }),
|
|
||||||
})
|
|
||||||
console.debug('Submit reply response status', res.status)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
console.debug('Submit reply response data', data)
|
|
||||||
const replyList = props.comment.reply || (props.comment.reply = [])
|
|
||||||
replyList.push({
|
|
||||||
id: data.id,
|
|
||||||
userName: data.author.username,
|
|
||||||
time: TimeManager.format(data.createdAt),
|
|
||||||
avatar: data.author.avatar,
|
|
||||||
medal: data.author.displayMedal,
|
|
||||||
text: data.content,
|
|
||||||
parentUserName: parentUserName,
|
|
||||||
reactions: [],
|
|
||||||
reply: (data.replies || []).map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
userName: r.author.username,
|
|
||||||
time: TimeManager.format(r.createdAt),
|
|
||||||
avatar: r.author.avatar,
|
|
||||||
text: r.content,
|
|
||||||
reactions: r.reactions || [],
|
|
||||||
reply: [],
|
|
||||||
openReplies: false,
|
|
||||||
src: r.author.avatar,
|
|
||||||
iconClick: () => router.push(`/users/${r.author.id}`),
|
|
||||||
})),
|
|
||||||
openReplies: false,
|
|
||||||
src: data.author.avatar,
|
|
||||||
iconClick: () => router.push(`/users/${data.author.id}`),
|
|
||||||
})
|
|
||||||
clear()
|
|
||||||
showEditor.value = false
|
|
||||||
toast.success('回复成功')
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
toast.error('回复过于频繁,请稍后再试')
|
|
||||||
} else {
|
|
||||||
toast.error(`回复失败: ${res.status} ${res.statusText}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug('Submit reply error', e)
|
|
||||||
toast.error(`回复失败: ${e.message}`)
|
|
||||||
} finally {
|
|
||||||
isWaitingForReply.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const copyCommentLink = () => {
|
|
||||||
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
|
||||||
navigator.clipboard.writeText(link).then(() => {
|
|
||||||
toast.success('已复制')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const handleContentClick = (e) => {
|
|
||||||
handleMarkdownClick(e)
|
|
||||||
if (e.target.tagName === 'IMG') {
|
|
||||||
const container = e.target.parentNode
|
|
||||||
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
|
|
||||||
lightboxImgs.value = imgs
|
|
||||||
lightboxIndex.value = imgs.indexOf(e.target.src)
|
|
||||||
lightboxVisible.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
showReplies,
|
|
||||||
toggleReplies,
|
|
||||||
showEditor,
|
|
||||||
toggleEditor,
|
|
||||||
submitReply,
|
|
||||||
copyCommentLink,
|
|
||||||
renderMarkdown,
|
|
||||||
isWaitingForReply,
|
|
||||||
commentMenuItems,
|
|
||||||
deleteComment,
|
|
||||||
pinComment,
|
|
||||||
unpinComment,
|
|
||||||
isPostAuthor,
|
|
||||||
lightboxVisible,
|
|
||||||
lightboxIndex,
|
|
||||||
lightboxImgs,
|
|
||||||
handleContentClick,
|
|
||||||
loggedIn,
|
|
||||||
replyCount,
|
|
||||||
replyList,
|
|
||||||
getMedalTitle,
|
|
||||||
editorWrapper,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
defaultShowReplies: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
postAuthorId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['deleted'])
|
||||||
|
|
||||||
|
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
||||||
|
watch(
|
||||||
|
() => props.defaultShowReplies,
|
||||||
|
(val) => {
|
||||||
|
showReplies.value = props.level === 0 ? true : val
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const showEditor = ref(false)
|
||||||
|
const editorWrapper = ref(null)
|
||||||
|
const isWaitingForReply = ref(false)
|
||||||
|
const lightboxVisible = ref(false)
|
||||||
|
const lightboxIndex = ref(0)
|
||||||
|
const lightboxImgs = ref([])
|
||||||
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
|
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||||
|
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||||
|
|
||||||
|
const toggleReplies = () => {
|
||||||
|
showReplies.value = !showReplies.value
|
||||||
}
|
}
|
||||||
|
|
||||||
CommentItem.components = {
|
const toggleEditor = () => {
|
||||||
CommentItem,
|
showEditor.value = !showEditor.value
|
||||||
CommentEditor,
|
if (showEditor.value) {
|
||||||
BaseTimeline,
|
setTimeout(() => {
|
||||||
ReactionsGroup,
|
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
DropdownMenu,
|
}, 100)
|
||||||
VueEasyLightbox,
|
}
|
||||||
LoginOverlay,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CommentItem
|
const flattenReplies = (list) => {
|
||||||
|
let result = []
|
||||||
|
for (const r of list) {
|
||||||
|
result.push(r)
|
||||||
|
if (r.reply && r.reply.length > 0) {
|
||||||
|
result = result.concat(flattenReplies(r.reply))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyList = computed(() => {
|
||||||
|
if (props.level < 1) {
|
||||||
|
return props.comment.reply
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattenReplies(props.comment.reply || [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAuthor = computed(() => authState.username === props.comment.userName)
|
||||||
|
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
|
||||||
|
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||||
|
const commentMenuItems = computed(() => {
|
||||||
|
const items = []
|
||||||
|
if (isAuthor.value || isAdmin.value) {
|
||||||
|
items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() })
|
||||||
|
}
|
||||||
|
if (isAdmin.value || isPostAuthor.value) {
|
||||||
|
if (props.comment.pinned) {
|
||||||
|
items.push({ text: '取消置顶', onClick: () => unpinComment() })
|
||||||
|
} else {
|
||||||
|
items.push({ text: '置顶', onClick: () => pinComment() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
const deleteComment = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.debug('Deleting comment', props.comment.id)
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
console.debug('Delete comment response status', res.status)
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('已删除')
|
||||||
|
emit('deleted', props.comment.id)
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const submitReply = async (parentUserName, text, clear) => {
|
||||||
|
if (!text.trim()) return
|
||||||
|
isWaitingForReply.value = true
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
isWaitingForReply.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.debug('Submitting reply', { parentId: props.comment.id, text })
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ content: text }),
|
||||||
|
})
|
||||||
|
console.debug('Submit reply response status', res.status)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
console.debug('Submit reply response data', data)
|
||||||
|
const replyList = props.comment.reply || (props.comment.reply = [])
|
||||||
|
replyList.push({
|
||||||
|
id: data.id,
|
||||||
|
userName: data.author.username,
|
||||||
|
time: TimeManager.format(data.createdAt),
|
||||||
|
avatar: data.author.avatar,
|
||||||
|
medal: data.author.displayMedal,
|
||||||
|
text: data.content,
|
||||||
|
parentUserName: parentUserName,
|
||||||
|
reactions: [],
|
||||||
|
reply: (data.replies || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
userName: r.author.username,
|
||||||
|
time: TimeManager.format(r.createdAt),
|
||||||
|
avatar: r.author.avatar,
|
||||||
|
text: r.content,
|
||||||
|
reactions: r.reactions || [],
|
||||||
|
reply: [],
|
||||||
|
openReplies: false,
|
||||||
|
src: r.author.avatar,
|
||||||
|
iconClick: () => navigateTo(`/users/${r.author.id}`),
|
||||||
|
})),
|
||||||
|
openReplies: false,
|
||||||
|
src: data.author.avatar,
|
||||||
|
iconClick: () => navigateTo(`/users/${data.author.id}`),
|
||||||
|
})
|
||||||
|
clear()
|
||||||
|
showEditor.value = false
|
||||||
|
toast.success('回复成功')
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('回复过于频繁,请稍后再试')
|
||||||
|
} else {
|
||||||
|
toast.error(`回复失败: ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('Submit reply error', e)
|
||||||
|
toast.error(`回复失败: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
isWaitingForReply.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinComment = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = isAdmin.value
|
||||||
|
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin`
|
||||||
|
: `${API_BASE_URL}/api/comments/${props.comment.id}/pin`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
props.comment.pinned = true
|
||||||
|
toast.success('已置顶')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unpinComment = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = isAdmin.value
|
||||||
|
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin`
|
||||||
|
: `${API_BASE_URL}/api/comments/${props.comment.id}/unpin`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
props.comment.pinned = false
|
||||||
|
toast.success('已取消置顶')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCommentLink = () => {
|
||||||
|
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
||||||
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
|
toast.success('已复制')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentClick = (e) => {
|
||||||
|
handleMarkdownClick(e)
|
||||||
|
if (e.target.tagName === 'IMG') {
|
||||||
|
const container = e.target.parentNode
|
||||||
|
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
|
||||||
|
lightboxImgs.value = imgs
|
||||||
|
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||||||
|
lightboxVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -312,7 +312,7 @@ export default {
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
z-index: 1300;
|
z-index: 1300;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,22 +3,24 @@
|
|||||||
<div class="dropdown-trigger" @click="toggle">
|
<div class="dropdown-trigger" @click="toggle">
|
||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="visible" class="dropdown-menu-container">
|
<Transition name="dropdown-menu">
|
||||||
<div
|
<div v-if="visible" class="dropdown-menu-container">
|
||||||
v-for="(item, idx) in items"
|
<div
|
||||||
:key="idx"
|
v-for="(item, idx) in items"
|
||||||
class="dropdown-item"
|
:key="idx"
|
||||||
:style="{ color: item.color || 'inherit' }"
|
class="dropdown-item"
|
||||||
@click="handle(item)"
|
:style="{ color: item.color || 'inherit' }"
|
||||||
>
|
@click="handle(item)"
|
||||||
{{ item.text }}
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'DropdownMenu',
|
name: 'DropdownMenu',
|
||||||
props: {
|
props: {
|
||||||
@@ -61,17 +63,28 @@ export default {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-trigger {
|
.dropdown-trigger {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu-enter-active,
|
||||||
|
.dropdown-menu-leave-active {
|
||||||
|
transition: all 0.4s;
|
||||||
|
}
|
||||||
|
.dropdown-menu-enter-from,
|
||||||
|
.dropdown-menu-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-16px);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu-container {
|
.dropdown-menu-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -82,7 +95,9 @@ export default {
|
|||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item:hover {
|
.dropdown-item:hover {
|
||||||
background-color: var(--menu-selected-background-color);
|
background-color: var(--menu-selected-background-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,95 +11,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import ActivityPopup from '~/components/ActivityPopup.vue'
|
import ActivityPopup from '~/components/ActivityPopup.vue'
|
||||||
import MedalPopup from '~/components/MedalPopup.vue'
|
import MedalPopup from '~/components/MedalPopup.vue'
|
||||||
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
|
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { authState } from '~/utils/auth'
|
import { authState } from '~/utils/auth'
|
||||||
|
|
||||||
export default {
|
const config = useRuntimeConfig()
|
||||||
name: 'GlobalPopups',
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
|
|
||||||
data() {
|
const showMilkTeaPopup = ref(false)
|
||||||
return {
|
const milkTeaIcon = ref('')
|
||||||
showMilkTeaPopup: false,
|
const showNotificationPopup = ref(false)
|
||||||
milkTeaIcon: '',
|
const showMedalPopup = ref(false)
|
||||||
showNotificationPopup: false,
|
const newMedals = ref([])
|
||||||
showMedalPopup: false,
|
|
||||||
newMedals: [],
|
onMounted(async () => {
|
||||||
|
await checkMilkTeaActivity()
|
||||||
|
if (showMilkTeaPopup.value) return
|
||||||
|
|
||||||
|
await checkNotificationSetting()
|
||||||
|
if (showNotificationPopup.value) return
|
||||||
|
|
||||||
|
await checkNewMedals()
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkMilkTeaActivity = async () => {
|
||||||
|
if (!process.client) return
|
||||||
|
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||||
|
if (res.ok) {
|
||||||
|
const list = await res.json()
|
||||||
|
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
|
||||||
|
if (a) {
|
||||||
|
milkTeaIcon.value = a.icon
|
||||||
|
showMilkTeaPopup.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
async mounted() {
|
// ignore network errors
|
||||||
await this.checkMilkTeaActivity()
|
}
|
||||||
if (this.showMilkTeaPopup) return
|
}
|
||||||
|
const closeMilkTeaPopup = () => {
|
||||||
await this.checkNotificationSetting()
|
if (!process.client) return
|
||||||
if (this.showNotificationPopup) return
|
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||||
|
showMilkTeaPopup.value = false
|
||||||
await this.checkNewMedals()
|
checkNotificationSetting()
|
||||||
},
|
}
|
||||||
methods: {
|
const checkNotificationSetting = async () => {
|
||||||
async checkMilkTeaActivity() {
|
if (!process.client) return
|
||||||
if (!process.client) return
|
if (!authState.loggedIn) return
|
||||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
if (localStorage.getItem('notificationSettingPopupShown')) return
|
||||||
try {
|
showNotificationPopup.value = true
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
}
|
||||||
if (res.ok) {
|
const closeNotificationPopup = () => {
|
||||||
const list = await res.json()
|
if (!process.client) return
|
||||||
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
|
localStorage.setItem('notificationSettingPopupShown', 'true')
|
||||||
if (a) {
|
showNotificationPopup.value = false
|
||||||
this.milkTeaIcon = a.icon
|
checkNewMedals()
|
||||||
this.showMilkTeaPopup = true
|
}
|
||||||
}
|
const checkNewMedals = async () => {
|
||||||
}
|
if (!process.client) return
|
||||||
} catch (e) {
|
if (!authState.loggedIn || !authState.userId) return
|
||||||
// ignore network errors
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const medals = await res.json()
|
||||||
|
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
|
||||||
|
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
|
||||||
|
if (m.length > 0) {
|
||||||
|
newMedals.value = m
|
||||||
|
showMedalPopup.value = true
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
closeMilkTeaPopup() {
|
} catch (e) {
|
||||||
if (!process.client) return
|
// ignore errors
|
||||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
}
|
||||||
this.showMilkTeaPopup = false
|
}
|
||||||
this.checkNotificationSetting()
|
const closeMedalPopup = () => {
|
||||||
},
|
if (!process.client) return
|
||||||
async checkNotificationSetting() {
|
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
||||||
if (!process.client) return
|
newMedals.value.forEach((m) => seen.add(m.type))
|
||||||
if (!authState.loggedIn) return
|
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
||||||
if (localStorage.getItem('notificationSettingPopupShown')) return
|
showMedalPopup.value = false
|
||||||
this.showNotificationPopup = true
|
|
||||||
},
|
|
||||||
closeNotificationPopup() {
|
|
||||||
if (!process.client) return
|
|
||||||
localStorage.setItem('notificationSettingPopupShown', 'true')
|
|
||||||
this.showNotificationPopup = false
|
|
||||||
this.checkNewMedals()
|
|
||||||
},
|
|
||||||
async checkNewMedals() {
|
|
||||||
if (!process.client) return
|
|
||||||
if (!authState.loggedIn || !authState.userId) return
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
|
|
||||||
if (res.ok) {
|
|
||||||
const medals = await res.json()
|
|
||||||
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
|
|
||||||
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
|
|
||||||
if (m.length > 0) {
|
|
||||||
this.newMedals = m
|
|
||||||
this.showMedalPopup = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore errors
|
|
||||||
}
|
|
||||||
},
|
|
||||||
closeMedalPopup() {
|
|
||||||
if (!process.client) return
|
|
||||||
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
|
||||||
this.newMedals.forEach((m) => seen.add(m.type))
|
|
||||||
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
|
||||||
this.showMedalPopup = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="logo-container" @click="goToHome">
|
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
|
||||||
<img
|
<img
|
||||||
alt="OpenIsle"
|
alt="OpenIsle"
|
||||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||||
@@ -16,15 +16,26 @@
|
|||||||
height="60"
|
height="60"
|
||||||
/>
|
/>
|
||||||
<div class="logo-text">OpenIsle</div>
|
<div class="logo-text">OpenIsle</div>
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="isLogin" class="header-content-right">
|
<div class="header-content-right">
|
||||||
<div v-if="isMobile" class="search-icon" @click="search">
|
<div v-if="isMobile" class="search-icon" @click="search">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
|
||||||
|
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
||||||
|
<i :class="iconClass"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||||
|
<div class="new-post-icon" @click="goToNewPost">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</div>
|
||||||
|
</ToolTip>
|
||||||
|
|
||||||
|
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||||
@@ -32,14 +43,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="header-content-right">
|
<div v-if="!isLogin" class="auth-btns">
|
||||||
<div v-if="isMobile" class="search-icon" @click="search">
|
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||||
<i class="fas fa-search"></i>
|
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
|
||||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
@@ -48,160 +56,139 @@
|
|||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ClientOnly } from '#components'
|
import { ClientOnly } from '#components'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
|
import ToolTip from '~/components/ToolTip.vue'
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'HeaderComponent',
|
showMenuBtn: {
|
||||||
components: { DropdownMenu, SearchDropdown },
|
type: Boolean,
|
||||||
props: {
|
default: true,
|
||||||
showMenuBtn: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup(props, { expose }) {
|
})
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const unreadCount = computed(() => notificationState.unreadCount)
|
|
||||||
const router = useRouter()
|
|
||||||
const avatar = ref('')
|
|
||||||
const showSearch = ref(false)
|
|
||||||
const searchDropdown = ref(null)
|
|
||||||
const userMenu = ref(null)
|
|
||||||
const menuBtn = ref(null)
|
|
||||||
|
|
||||||
expose({
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
menuBtn,
|
const isMobile = useIsMobile()
|
||||||
})
|
const unreadCount = computed(() => notificationState.unreadCount)
|
||||||
|
const avatar = ref('')
|
||||||
|
const showSearch = ref(false)
|
||||||
|
const searchDropdown = ref(null)
|
||||||
|
const userMenu = ref(null)
|
||||||
|
const menuBtn = ref(null)
|
||||||
|
|
||||||
const goToHome = () => {
|
const search = () => {
|
||||||
router.push('/').then(() => {
|
showSearch.value = true
|
||||||
window.location.reload()
|
nextTick(() => {
|
||||||
})
|
searchDropdown.value.toggle()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const closeSearch = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
showSearch.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const goToLogin = () => {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
}
|
||||||
|
const goToSettings = () => {
|
||||||
|
navigateTo('/settings', { replace: true })
|
||||||
|
}
|
||||||
|
const goToProfile = async () => {
|
||||||
|
if (!authState.loggedIn) {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let id = authState.username || authState.userId
|
||||||
|
if (!id) {
|
||||||
|
const user = await loadCurrentUser()
|
||||||
|
if (user) {
|
||||||
|
id = user.username || user.id
|
||||||
}
|
}
|
||||||
const search = () => {
|
}
|
||||||
showSearch.value = true
|
if (id) {
|
||||||
nextTick(() => {
|
navigateTo(`/users/${id}`, { replace: true })
|
||||||
searchDropdown.value.toggle()
|
}
|
||||||
})
|
}
|
||||||
}
|
const goToSignup = () => {
|
||||||
const closeSearch = () => {
|
navigateTo('/signup', { replace: true })
|
||||||
nextTick(() => {
|
}
|
||||||
showSearch.value = false
|
const goToLogout = () => {
|
||||||
})
|
clearToken()
|
||||||
}
|
navigateTo('/login', { replace: true })
|
||||||
const goToLogin = () => {
|
}
|
||||||
router.push('/login')
|
|
||||||
}
|
const goToNewPost = () => {
|
||||||
const goToSettings = () => {
|
navigateTo('/new-post', { replace: false })
|
||||||
router.push('/settings')
|
}
|
||||||
}
|
|
||||||
const goToProfile = async () => {
|
const refrechData = async () => {
|
||||||
if (!authState.loggedIn) {
|
await fetchUnreadCount()
|
||||||
router.push('/login')
|
window.dispatchEvent(new Event('refresh-home'))
|
||||||
return
|
}
|
||||||
}
|
|
||||||
let id = authState.username || authState.userId
|
const headerMenuItems = computed(() => [
|
||||||
if (!id) {
|
{ text: '设置', onClick: goToSettings },
|
||||||
const user = await loadCurrentUser()
|
{ text: '个人主页', onClick: goToProfile },
|
||||||
if (user) {
|
{ text: '退出', onClick: goToLogout },
|
||||||
id = user.username || user.id
|
])
|
||||||
}
|
|
||||||
}
|
/** 其余逻辑保持不变 */
|
||||||
if (id) {
|
const iconClass = computed(() => {
|
||||||
router.push(`/users/${id}`)
|
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) {
|
||||||
|
const user = await loadCurrentUser()
|
||||||
|
if (user && user.avatar) {
|
||||||
|
avatar.value = user.avatar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const goToSignup = () => {
|
}
|
||||||
router.push('/signup')
|
const updateUnread = async () => {
|
||||||
}
|
if (authState.loggedIn) {
|
||||||
const goToLogout = () => {
|
await fetchUnreadCount()
|
||||||
clearToken()
|
} else {
|
||||||
this.$router.push('/login')
|
notificationState.unreadCount = 0
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const headerMenuItems = computed(() => [
|
await updateAvatar()
|
||||||
{ text: '设置', onClick: goToSettings },
|
await updateUnread()
|
||||||
{ text: '个人主页', onClick: goToProfile },
|
|
||||||
{ text: '退出', onClick: goToLogout },
|
|
||||||
])
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const updateAvatar = async () => {
|
|
||||||
if (authState.loggedIn) {
|
|
||||||
const user = await loadCurrentUser()
|
|
||||||
if (user && user.avatar) {
|
|
||||||
avatar.value = user.avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updateUnread = async () => {
|
|
||||||
if (authState.loggedIn) {
|
|
||||||
await fetchUnreadCount()
|
|
||||||
} else {
|
|
||||||
notificationState.unreadCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authState.loggedIn,
|
||||||
|
async () => {
|
||||||
await updateAvatar()
|
await updateAvatar()
|
||||||
await updateUnread()
|
await updateUnread()
|
||||||
|
},
|
||||||
watch(
|
)
|
||||||
() => authState.loggedIn,
|
})
|
||||||
async () => {
|
|
||||||
await updateAvatar()
|
|
||||||
await updateUnread()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => router.currentRoute.value.fullPath,
|
|
||||||
() => {
|
|
||||||
if (userMenu.value) userMenu.value.close()
|
|
||||||
showSearch.value = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLogin,
|
|
||||||
isMobile,
|
|
||||||
headerMenuItems,
|
|
||||||
unreadCount,
|
|
||||||
goToHome,
|
|
||||||
search,
|
|
||||||
closeSearch,
|
|
||||||
goToLogin,
|
|
||||||
goToSettings,
|
|
||||||
goToProfile,
|
|
||||||
goToSignup,
|
|
||||||
goToLogout,
|
|
||||||
showSearch,
|
|
||||||
searchDropdown,
|
|
||||||
userMenu,
|
|
||||||
avatar,
|
|
||||||
menuBtn,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: var(--blur-10);
|
||||||
color: var(--header-text-color);
|
color: var(--header-text-color);
|
||||||
border-bottom: 1px solid var(--header-border-color);
|
border-bottom: 1px solid var(--header-border-color);
|
||||||
}
|
}
|
||||||
@@ -212,17 +199,18 @@ export default {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
max-width: var(--page-max-width);
|
max-width: var(--page-max-width);
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content-left {
|
.header-content-left {
|
||||||
@@ -232,6 +220,14 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-content-right {
|
.header-content-right {
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -313,7 +309,13 @@ export default {
|
|||||||
background-color: var(--menu-selected-background-color);
|
background-color: var(--menu-selected-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon,
|
||||||
|
.theme-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-post-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
const goLogin = () => {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
export default {
|
|
||||||
name: 'LoginOverlay',
|
|
||||||
setup() {
|
|
||||||
const router = useRouter()
|
|
||||||
const goLogin = () => {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
return { goLogin }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,7 +35,7 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: var(--blur-4);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,33 +16,25 @@
|
|||||||
</BasePopup>
|
</BasePopup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BasePopup from '~/components/BasePopup.vue'
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { authState } from '~/utils/auth'
|
import { authState } from '~/utils/auth'
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'MedalPopup',
|
visible: { type: Boolean, default: false },
|
||||||
components: { BasePopup },
|
medals: { type: Array, default: () => [] },
|
||||||
props: {
|
})
|
||||||
visible: { type: Boolean, default: false },
|
const emit = defineEmits(['close'])
|
||||||
medals: { type: Array, default: () => [] },
|
|
||||||
},
|
const gotoMedals = () => {
|
||||||
emits: ['close'],
|
emit('close')
|
||||||
setup(props, { emit }) {
|
if (authState.username) {
|
||||||
const router = useRouter()
|
navigateTo(`/users/${authState.username}?tab=achievements`, { replace: true })
|
||||||
const gotoMedals = () => {
|
} else {
|
||||||
emit('close')
|
navigateTo('/', { replace: true })
|
||||||
if (authState.username) {
|
}
|
||||||
router.push(`/users/${authState.username}?tab=achievements`)
|
|
||||||
} else {
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const close = () => emit('close')
|
|
||||||
return { gotoMedals, close }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const close = () => emit('close')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,246 +1,258 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<nav v-if="visible" class="menu">
|
<nav v-if="visible" class="menu">
|
||||||
<div class="menu-item-container">
|
<div class="menu-content">
|
||||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
|
<div class="menu-item-container">
|
||||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||||
<span class="menu-item-text">话题</span>
|
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||||
</NuxtLink>
|
<span class="menu-item-text">话题</span>
|
||||||
<NuxtLink
|
</NuxtLink>
|
||||||
class="menu-item"
|
<NuxtLink
|
||||||
exact-active-class="selected"
|
class="menu-item"
|
||||||
to="/message"
|
exact-active-class="selected"
|
||||||
@click="handleItemClick"
|
to="/new-post"
|
||||||
>
|
@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)"
|
|
||||||
>
|
>
|
||||||
<template v-if="c.smallIcon || c.icon">
|
<i class="menu-item-icon fas fa-edit"></i>
|
||||||
<img
|
<span class="menu-item-text">发帖</span>
|
||||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
</NuxtLink>
|
||||||
:src="c.smallIcon || c.icon"
|
<NuxtLink
|
||||||
class="section-item-icon"
|
class="menu-item"
|
||||||
:alt="c.name"
|
exact-active-class="selected"
|
||||||
/>
|
to="/message"
|
||||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
@click="handleItemClick"
|
||||||
</template>
|
>
|
||||||
<span class="section-item-text">
|
<i class="menu-item-icon fas fa-envelope"></i>
|
||||||
{{ c.name }}
|
<span class="menu-item-text">我的消息</span>
|
||||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
<span v-if="unreadCount > 0" class="unread-container">
|
||||||
|
<span class="unread"> {{ showUnreadCount }} </span>
|
||||||
</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="/about/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>
|
|
||||||
|
|
||||||
<div class="menu-section">
|
<div class="menu-section">
|
||||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||||
<span>tag</span>
|
<span>类别</span>
|
||||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
<i :class="categoryOpen ? '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>
|
||||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
<div v-if="categoryOpen" class="section-items">
|
||||||
<img
|
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
:src="t.smallIcon || t.icon"
|
</div>
|
||||||
class="section-item-icon"
|
<div
|
||||||
:alt="t.name"
|
v-else
|
||||||
/>
|
v-for="c in categoryData"
|
||||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
:key="c.id"
|
||||||
<span class="section-item-text"
|
class="section-item"
|
||||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-footer">
|
<!-- 解决动态样式的水合错误 -->
|
||||||
<div class="menu-footer-btn" @click="cycleTheme">
|
<ClientOnly v-if="!isMobile">
|
||||||
<i :class="iconClass"></i>
|
<div class="menu-footer">
|
||||||
|
<div class="menu-footer-btn" @click="cycleTheme">
|
||||||
|
<i :class="iconClass"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ClientOnly>
|
||||||
</nav>
|
</nav>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { authState } from '~/utils/auth'
|
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
import { API_BASE_URL } from '~/main'
|
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
||||||
|
|
||||||
export default {
|
const isMobile = useIsMobile()
|
||||||
name: 'MenuComponent',
|
|
||||||
props: {
|
const config = useRuntimeConfig()
|
||||||
visible: {
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['item-click'])
|
||||||
|
|
||||||
|
const categoryOpen = ref(true)
|
||||||
|
const tagOpen = ref(true)
|
||||||
|
const myPoint = ref(null)
|
||||||
|
|
||||||
|
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||||
|
const {
|
||||||
|
data: categoryData,
|
||||||
|
pending: isLoadingCategory,
|
||||||
|
error: categoryError,
|
||||||
|
} = await useAsyncData(
|
||||||
|
// 稳定 key:避免 hydration 期误判
|
||||||
|
'menu:categories',
|
||||||
|
() => $fetch(`${API_BASE_URL}/api/categories`),
|
||||||
|
{
|
||||||
|
server: true, // SSR 预取
|
||||||
|
default: () => [], // 初始默认值,减少空判断
|
||||||
|
// 5 分钟内复用缓存,避免路由往返重复请求
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return 'fas fa-moon'
|
||||||
|
case ThemeMode.LIGHT:
|
||||||
|
return 'fas fa-sun'
|
||||||
|
default:
|
||||||
|
return 'fas fa-desktop'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
} else {
|
||||||
|
notificationState.unreadCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([updateCount(), loadPoint()])
|
||||||
|
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||||
|
watch(
|
||||||
|
() => authState.loggedIn,
|
||||||
|
() => {
|
||||||
|
updateCount()
|
||||||
|
loadPoint()
|
||||||
},
|
},
|
||||||
},
|
)
|
||||||
async setup(props, { emit }) {
|
})
|
||||||
const router = useRouter()
|
|
||||||
const categories = ref([])
|
|
||||||
const tags = ref([])
|
|
||||||
const categoryOpen = ref(true)
|
|
||||||
const tagOpen = ref(true)
|
|
||||||
const isLoadingCategory = ref(false)
|
|
||||||
const isLoadingTag = ref(false)
|
|
||||||
const categoryData = ref([])
|
|
||||||
const tagData = ref([])
|
|
||||||
|
|
||||||
const fetchCategoryData = async () => {
|
const handleItemClick = () => {
|
||||||
isLoadingCategory.value = true
|
if (window.innerWidth <= 768) emit('item-click')
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
}
|
||||||
const data = await res.json()
|
|
||||||
categoryData.value = data
|
|
||||||
isLoadingCategory.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchTagData = async () => {
|
const isImageIcon = (icon) => {
|
||||||
isLoadingTag.value = true
|
if (!icon) return false
|
||||||
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
const data = await res.json()
|
}
|
||||||
tagData.value = data
|
|
||||||
isLoadingTag.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconClass = computed(() => {
|
const gotoCategory = (c) => {
|
||||||
switch (themeState.mode) {
|
const value = encodeURIComponent(c.id ?? c.name)
|
||||||
case ThemeMode.DARK:
|
navigateTo({ path: '/', query: { category: value } }, { replace: true })
|
||||||
return 'fas fa-moon'
|
handleItemClick()
|
||||||
case ThemeMode.LIGHT:
|
}
|
||||||
return 'fas fa-sun'
|
|
||||||
default:
|
|
||||||
return 'fas fa-desktop'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unreadCount = computed(() => notificationState.unreadCount)
|
const gotoTag = (t) => {
|
||||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
const value = encodeURIComponent(t.id ?? t.name)
|
||||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||||
|
handleItemClick()
|
||||||
const updateCount = async () => {
|
|
||||||
if (authState.loggedIn) {
|
|
||||||
await fetchUnreadCount()
|
|
||||||
} else {
|
|
||||||
notificationState.unreadCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await updateCount()
|
|
||||||
watch(() => authState.loggedIn, updateCount)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleHomeClick = () => {
|
|
||||||
router.push('/').then(() => {
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemClick = () => {
|
|
||||||
if (window.innerWidth <= 768) emit('item-click')
|
|
||||||
}
|
|
||||||
|
|
||||||
const isImageIcon = (icon) => {
|
|
||||||
if (!icon) return false
|
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotoCategory = (c) => {
|
|
||||||
const value = encodeURIComponent(c.id ?? c.name)
|
|
||||||
router.push({ path: '/', query: { category: value } })
|
|
||||||
handleItemClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotoTag = (t) => {
|
|
||||||
const value = encodeURIComponent(t.id ?? t.name)
|
|
||||||
router.push({ path: '/', query: { tags: value } })
|
|
||||||
handleItemClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
|
||||||
|
|
||||||
return {
|
|
||||||
categoryData,
|
|
||||||
tagData,
|
|
||||||
categoryOpen,
|
|
||||||
tagOpen,
|
|
||||||
isLoadingCategory,
|
|
||||||
isLoadingTag,
|
|
||||||
iconClass,
|
|
||||||
unreadCount,
|
|
||||||
showUnreadCount,
|
|
||||||
shouldShowStats,
|
|
||||||
cycleTheme,
|
|
||||||
handleHomeClick,
|
|
||||||
handleItemClick,
|
|
||||||
isImageIcon,
|
|
||||||
gotoCategory,
|
|
||||||
gotoTag,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -248,20 +260,29 @@ export default {
|
|||||||
.menu {
|
.menu {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--header-height);
|
top: var(--header-height);
|
||||||
width: 200px;
|
width: 220px;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
height: calc(100vh - 20px - var(--header-height));
|
height: calc(100vh - 20px - var(--header-height));
|
||||||
border-right: 1px solid var(--menu-border-color);
|
border-right: 1px solid var(--menu-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 10px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item-container {
|
.menu-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 10px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .menu-item-container { */
|
||||||
|
/**/
|
||||||
|
/* } */
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -300,6 +321,12 @@ export default {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.point-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.menu-item-icon {
|
.menu-item-icon {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -307,10 +334,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-footer {
|
.menu-footer {
|
||||||
position: fixed;
|
position: relation;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
bottom: 10px;
|
|
||||||
right: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -398,6 +423,10 @@ export default {
|
|||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-content {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-enter-active,
|
.slide-enter-active,
|
||||||
.slide-leave-active {
|
.slide-leave-active {
|
||||||
transition:
|
transition:
|
||||||
|
|||||||
@@ -57,73 +57,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import BasePopup from '~/components/BasePopup.vue'
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
import LevelProgress from '~/components/LevelProgress.vue'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import ProgressBar from '~/components/ProgressBar.vue'
|
import ProgressBar from '~/components/ProgressBar.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const info = ref({ redeemCount: 0, ended: false })
|
||||||
name: 'MilkTeaActivityComponent',
|
const user = ref(null)
|
||||||
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
const dialogVisible = ref(false)
|
||||||
data() {
|
const contact = ref('')
|
||||||
return {
|
const loading = ref(false)
|
||||||
info: { redeemCount: 0, ended: false },
|
const isLoadingUser = ref(true)
|
||||||
user: null,
|
|
||||||
dialogVisible: false,
|
onMounted(async () => {
|
||||||
contact: '',
|
await loadInfo()
|
||||||
loading: false,
|
isLoadingUser.value = true
|
||||||
isLoadingUser: true,
|
user.value = await fetchCurrentUser()
|
||||||
|
isLoadingUser.value = false
|
||||||
|
})
|
||||||
|
const loadInfo = async () => {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||||
|
if (res.ok) {
|
||||||
|
info.value = await res.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const openDialog = () => {
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
const closeDialog = () => {
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
const submitRedeem = async () => {
|
||||||
|
if (!contact.value) return
|
||||||
|
loading.value = true
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ contact: contact.value }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.message === 'updated') {
|
||||||
|
toast.success('您已提交过兑换,本次更新兑换信息')
|
||||||
|
} else {
|
||||||
|
toast.success('兑换成功!')
|
||||||
}
|
}
|
||||||
},
|
dialogVisible.value = false
|
||||||
async mounted() {
|
await loadInfo()
|
||||||
await this.loadInfo()
|
} else {
|
||||||
this.isLoadingUser = true
|
toast.error('兑换失败')
|
||||||
this.user = await fetchCurrentUser()
|
}
|
||||||
this.isLoadingUser = false
|
loading.value = false
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async loadInfo() {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
|
||||||
if (res.ok) {
|
|
||||||
this.info = await res.json()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
openDialog() {
|
|
||||||
this.dialogVisible = true
|
|
||||||
},
|
|
||||||
closeDialog() {
|
|
||||||
this.dialogVisible = false
|
|
||||||
},
|
|
||||||
async submitRedeem() {
|
|
||||||
if (!this.contact) return
|
|
||||||
this.loading = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ contact: this.contact }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.message === 'updated') {
|
|
||||||
toast.success('您已提交过兑换,本次更新兑换信息')
|
|
||||||
} else {
|
|
||||||
toast.success('兑换成功!')
|
|
||||||
}
|
|
||||||
this.dialogVisible = false
|
|
||||||
await this.loadInfo()
|
|
||||||
} else {
|
|
||||||
toast.error('兑换失败')
|
|
||||||
}
|
|
||||||
this.loading = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -11,27 +11,19 @@
|
|||||||
</BasePopup>
|
</BasePopup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BasePopup from '~/components/BasePopup.vue'
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'NotificationSettingPopup',
|
visible: { type: Boolean, default: false },
|
||||||
components: { BasePopup },
|
})
|
||||||
props: {
|
const emit = defineEmits(['close'])
|
||||||
visible: { type: Boolean, default: false },
|
|
||||||
},
|
const gotoSetting = () => {
|
||||||
emits: ['close'],
|
emit('close')
|
||||||
setup(props, { emit }) {
|
navigateTo('/message?tab=control', { replace: true })
|
||||||
const router = useRouter()
|
|
||||||
const gotoSetting = () => {
|
|
||||||
emit('close')
|
|
||||||
router.push('/message?tab=control')
|
|
||||||
}
|
|
||||||
const close = () => emit('close')
|
|
||||||
return { gotoSetting, close }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const close = () => emit('close')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -46,11 +46,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { reactionEmojiMap } from '~/utils/reactions'
|
import { reactionEmojiMap } from '~/utils/reactions'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, default: () => [] },
|
||||||
|
contentType: { type: String, required: true },
|
||||||
|
contentId: { type: [Number, String], required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(v) => (reactions.value = v),
|
||||||
|
)
|
||||||
|
|
||||||
|
const reactions = ref(props.modelValue)
|
||||||
|
const reactionTypes = ref([])
|
||||||
|
|
||||||
let cachedTypes = null
|
let cachedTypes = null
|
||||||
const fetchTypes = async () => {
|
const fetchTypes = async () => {
|
||||||
@@ -71,151 +87,118 @@ const fetchTypes = async () => {
|
|||||||
return cachedTypes
|
return cachedTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'ReactionsGroup',
|
reactionTypes.value = await fetchTypes()
|
||||||
props: {
|
})
|
||||||
modelValue: { type: Array, default: () => [] },
|
|
||||||
contentType: { type: String, required: true },
|
|
||||||
contentId: { type: [Number, String], required: true },
|
|
||||||
},
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const reactions = ref(props.modelValue)
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(v) => (reactions.value = v),
|
|
||||||
)
|
|
||||||
|
|
||||||
const reactionTypes = ref([])
|
const counts = computed(() => {
|
||||||
onMounted(async () => {
|
const c = {}
|
||||||
reactionTypes.value = await fetchTypes()
|
for (const r of reactions.value) {
|
||||||
|
c[r.type] = (c[r.type] || 0) + 1
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||||
|
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
||||||
|
|
||||||
|
const userReacted = (type) =>
|
||||||
|
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||||
|
|
||||||
|
const displayedReactions = computed(() => {
|
||||||
|
return Object.entries(counts.value)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([type]) => ({ type }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
|
||||||
|
|
||||||
|
const panelVisible = ref(false)
|
||||||
|
let hideTimer = null
|
||||||
|
const openPanel = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
panelVisible.value = true
|
||||||
|
}
|
||||||
|
const scheduleHide = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
panelVisible.value = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
const cancelHide = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleReaction = async (type) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url =
|
||||||
|
props.contentType === 'post'
|
||||||
|
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
||||||
|
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
||||||
|
|
||||||
|
// optimistic update
|
||||||
|
const existingIdx = reactions.value.findIndex(
|
||||||
|
(r) => r.type === type && r.user === authState.username,
|
||||||
|
)
|
||||||
|
let tempReaction = null
|
||||||
|
let removedReaction = null
|
||||||
|
if (existingIdx > -1) {
|
||||||
|
removedReaction = reactions.value.splice(existingIdx, 1)[0]
|
||||||
|
} else {
|
||||||
|
tempReaction = { type, user: authState.username }
|
||||||
|
reactions.value.push(tempReaction)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', reactions.value)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ type }),
|
||||||
})
|
})
|
||||||
|
if (res.ok) {
|
||||||
const counts = computed(() => {
|
if (res.status === 204) {
|
||||||
const c = {}
|
// removal already reflected
|
||||||
for (const r of reactions.value) {
|
|
||||||
c[r.type] = (c[r.type] || 0) + 1
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
|
||||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
|
||||||
|
|
||||||
const userReacted = (type) =>
|
|
||||||
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
|
||||||
|
|
||||||
const displayedReactions = computed(() => {
|
|
||||||
return Object.entries(counts.value)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(([type]) => ({ type }))
|
|
||||||
})
|
|
||||||
|
|
||||||
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
|
|
||||||
|
|
||||||
const panelVisible = ref(false)
|
|
||||||
let hideTimer = null
|
|
||||||
const openPanel = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
panelVisible.value = true
|
|
||||||
}
|
|
||||||
const scheduleHide = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
hideTimer = setTimeout(() => {
|
|
||||||
panelVisible.value = false
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
const cancelHide = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleReaction = async (type) => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const url =
|
|
||||||
props.contentType === 'post'
|
|
||||||
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
|
||||||
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
|
||||||
|
|
||||||
// optimistic update
|
|
||||||
const existingIdx = reactions.value.findIndex(
|
|
||||||
(r) => r.type === type && r.user === authState.username,
|
|
||||||
)
|
|
||||||
let tempReaction = null
|
|
||||||
let removedReaction = null
|
|
||||||
if (existingIdx > -1) {
|
|
||||||
removedReaction = reactions.value.splice(existingIdx, 1)[0]
|
|
||||||
} else {
|
} else {
|
||||||
tempReaction = { type, user: authState.username }
|
const data = await res.json()
|
||||||
reactions.value.push(tempReaction)
|
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
|
||||||
|
if (idx > -1) {
|
||||||
|
reactions.value.splice(idx, 1, data)
|
||||||
|
} else if (removedReaction) {
|
||||||
|
// server added back reaction even though we removed? restore data
|
||||||
|
reactions.value.push(data)
|
||||||
|
}
|
||||||
|
if (data.reward && data.reward > 0) {
|
||||||
|
toast.success(`获得 ${data.reward} 经验值`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
emit('update:modelValue', reactions.value)
|
emit('update:modelValue', reactions.value)
|
||||||
|
} else {
|
||||||
try {
|
// revert optimistic update on failure
|
||||||
const res = await fetch(url, {
|
if (tempReaction) {
|
||||||
method: 'POST',
|
const idx = reactions.value.indexOf(tempReaction)
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
if (idx > -1) reactions.value.splice(idx, 1)
|
||||||
body: JSON.stringify({ type }),
|
} else if (removedReaction) {
|
||||||
})
|
reactions.value.push(removedReaction)
|
||||||
if (res.ok) {
|
|
||||||
if (res.status === 204) {
|
|
||||||
// removal already reflected
|
|
||||||
} else {
|
|
||||||
const data = await res.json()
|
|
||||||
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
|
|
||||||
if (idx > -1) {
|
|
||||||
reactions.value.splice(idx, 1, data)
|
|
||||||
} else if (removedReaction) {
|
|
||||||
// server added back reaction even though we removed? restore data
|
|
||||||
reactions.value.push(data)
|
|
||||||
}
|
|
||||||
if (data.reward && data.reward > 0) {
|
|
||||||
toast.success(`获得 ${data.reward} 经验值`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit('update:modelValue', reactions.value)
|
|
||||||
} else {
|
|
||||||
// revert optimistic update on failure
|
|
||||||
if (tempReaction) {
|
|
||||||
const idx = reactions.value.indexOf(tempReaction)
|
|
||||||
if (idx > -1) reactions.value.splice(idx, 1)
|
|
||||||
} else if (removedReaction) {
|
|
||||||
reactions.value.push(removedReaction)
|
|
||||||
}
|
|
||||||
emit('update:modelValue', reactions.value)
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (tempReaction) {
|
|
||||||
const idx = reactions.value.indexOf(tempReaction)
|
|
||||||
if (idx > -1) reactions.value.splice(idx, 1)
|
|
||||||
} else if (removedReaction) {
|
|
||||||
reactions.value.push(removedReaction)
|
|
||||||
}
|
|
||||||
emit('update:modelValue', reactions.value)
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
}
|
||||||
|
emit('update:modelValue', reactions.value)
|
||||||
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
return {
|
if (tempReaction) {
|
||||||
reactionEmojiMap,
|
const idx = reactions.value.indexOf(tempReaction)
|
||||||
counts,
|
if (idx > -1) reactions.value.splice(idx, 1)
|
||||||
totalCount,
|
} else if (removedReaction) {
|
||||||
likeCount,
|
reactions.value.push(removedReaction)
|
||||||
displayedReactions,
|
|
||||||
panelTypes,
|
|
||||||
panelVisible,
|
|
||||||
openPanel,
|
|
||||||
scheduleHide,
|
|
||||||
cancelHide,
|
|
||||||
toggleReaction,
|
|
||||||
userReacted,
|
|
||||||
}
|
}
|
||||||
},
|
emit('update:modelValue', reactions.value)
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -36,98 +36,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const emit = defineEmits(['close'])
|
||||||
name: 'SearchDropdown',
|
|
||||||
components: { Dropdown },
|
|
||||||
emits: ['close'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const router = useRouter()
|
|
||||||
const keyword = ref('')
|
|
||||||
const selected = ref(null)
|
|
||||||
const results = ref([])
|
|
||||||
const dropdown = ref(null)
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
|
|
||||||
const toggle = () => {
|
const keyword = ref('')
|
||||||
dropdown.value.toggle()
|
const selected = ref(null)
|
||||||
}
|
const results = ref([])
|
||||||
|
const dropdown = ref(null)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const onClose = () => emit('close')
|
const toggle = () => {
|
||||||
|
dropdown.value.toggle()
|
||||||
const fetchResults = async (kw) => {
|
|
||||||
if (!kw) return []
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
|
||||||
if (!res.ok) return []
|
|
||||||
const data = await res.json()
|
|
||||||
results.value = data.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
text: r.text,
|
|
||||||
type: r.type,
|
|
||||||
subText: r.subText,
|
|
||||||
extra: r.extra,
|
|
||||||
postId: r.postId,
|
|
||||||
}))
|
|
||||||
return results.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlight = (text) => {
|
|
||||||
text = stripMarkdown(text)
|
|
||||||
if (!keyword.value) return text
|
|
||||||
const reg = new RegExp(keyword.value, 'gi')
|
|
||||||
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
user: 'fas fa-user',
|
|
||||||
post: 'fas fa-file-alt',
|
|
||||||
comment: 'fas fa-comment',
|
|
||||||
category: 'fas fa-folder',
|
|
||||||
tag: 'fas fa-hashtag',
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selected, (val) => {
|
|
||||||
if (!val) return
|
|
||||||
const opt = results.value.find((r) => r.id === val)
|
|
||||||
if (!opt) return
|
|
||||||
if (opt.type === 'post' || opt.type === 'post_title') {
|
|
||||||
router.push(`/posts/${opt.id}`)
|
|
||||||
} else if (opt.type === 'user') {
|
|
||||||
router.push(`/users/${opt.id}`)
|
|
||||||
} else if (opt.type === 'comment') {
|
|
||||||
if (opt.postId) {
|
|
||||||
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
|
|
||||||
}
|
|
||||||
} else if (opt.type === 'category') {
|
|
||||||
router.push({ path: '/', query: { category: opt.id } })
|
|
||||||
} else if (opt.type === 'tag') {
|
|
||||||
router.push({ path: '/', query: { tags: opt.id } })
|
|
||||||
}
|
|
||||||
selected.value = null
|
|
||||||
keyword.value = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
keyword,
|
|
||||||
selected,
|
|
||||||
fetchResults,
|
|
||||||
highlight,
|
|
||||||
iconMap,
|
|
||||||
isMobile,
|
|
||||||
dropdown,
|
|
||||||
onClose,
|
|
||||||
toggle,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClose = () => emit('close')
|
||||||
|
|
||||||
|
const fetchResults = async (kw) => {
|
||||||
|
if (!kw) return []
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
results.value = data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
text: r.text,
|
||||||
|
type: r.type,
|
||||||
|
subText: r.subText,
|
||||||
|
extra: r.extra,
|
||||||
|
postId: r.postId,
|
||||||
|
}))
|
||||||
|
return results.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlight = (text) => {
|
||||||
|
text = stripMarkdown(text)
|
||||||
|
if (!keyword.value) return text
|
||||||
|
const reg = new RegExp(keyword.value, 'gi')
|
||||||
|
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
user: 'fas fa-user',
|
||||||
|
post: 'fas fa-file-alt',
|
||||||
|
comment: 'fas fa-comment',
|
||||||
|
category: 'fas fa-folder',
|
||||||
|
tag: 'fas fa-hashtag',
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selected, (val) => {
|
||||||
|
if (!val) return
|
||||||
|
const opt = results.value.find((r) => r.id === val)
|
||||||
|
if (!opt) return
|
||||||
|
if (opt.type === 'post' || opt.type === 'post_title') {
|
||||||
|
navigateTo(`/posts/${opt.id}`, { replace: true })
|
||||||
|
} else if (opt.type === 'user') {
|
||||||
|
navigateTo(`/users/${opt.id}`, { replace: true })
|
||||||
|
} else if (opt.type === 'comment') {
|
||||||
|
if (opt.postId) {
|
||||||
|
navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
|
||||||
|
}
|
||||||
|
} else if (opt.type === 'category') {
|
||||||
|
navigateTo({ path: '/', query: { category: opt.id } }, { replace: true })
|
||||||
|
} else if (opt.type === 'tag') {
|
||||||
|
navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
|
||||||
|
}
|
||||||
|
selected.value = null
|
||||||
|
keyword.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggle,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -149,7 +135,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -28,114 +28,105 @@
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const emit = defineEmits(['update:modelValue'])
|
||||||
name: 'TagSelect',
|
const props = defineProps({
|
||||||
components: { Dropdown },
|
modelValue: { type: Array, default: () => [] },
|
||||||
props: {
|
creatable: { type: Boolean, default: false },
|
||||||
modelValue: { type: Array, default: () => [] },
|
options: { type: Array, default: () => [] },
|
||||||
creatable: { type: Boolean, default: false },
|
})
|
||||||
options: { type: Array, default: () => [] },
|
|
||||||
|
const localTags = ref([])
|
||||||
|
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
(val) => {
|
||||||
|
providedTags.value = Array.isArray(val) ? [...val] : []
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
)
|
||||||
setup(props, { emit }) {
|
|
||||||
const localTags = ref([])
|
|
||||||
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
|
||||||
|
|
||||||
watch(
|
const mergedOptions = computed(() => {
|
||||||
() => props.options,
|
const arr = [...providedTags.value, ...localTags.value]
|
||||||
(val) => {
|
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||||
providedTags.value = Array.isArray(val) ? [...val] : []
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const mergedOptions = computed(() => {
|
const isImageIcon = (icon) => {
|
||||||
const arr = [...providedTags.value, ...localTags.value]
|
if (!icon) return false
|
||||||
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
})
|
|
||||||
|
|
||||||
const isImageIcon = (icon) => {
|
|
||||||
if (!icon) return false
|
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildTagsUrl = (kw = '') => {
|
|
||||||
const base = API_BASE_URL || (process.client ? window.location.origin : '')
|
|
||||||
const url = new URL('/api/tags', base)
|
|
||||||
|
|
||||||
if (kw) url.searchParams.set('keyword', kw)
|
|
||||||
url.searchParams.set('limit', '10')
|
|
||||||
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchTags = async (kw = '') => {
|
|
||||||
const defaultOption = { id: 0, name: '无标签' }
|
|
||||||
|
|
||||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
|
||||||
const url = buildTagsUrl(kw)
|
|
||||||
|
|
||||||
// 2) 拉数据
|
|
||||||
let data = []
|
|
||||||
try {
|
|
||||||
const res = await fetch(url)
|
|
||||||
if (res.ok) data = await res.json()
|
|
||||||
} catch {
|
|
||||||
toast.error('获取标签失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 合并、去重、可创建
|
|
||||||
let options = [...data, ...localTags.value]
|
|
||||||
|
|
||||||
if (
|
|
||||||
props.creatable &&
|
|
||||||
kw &&
|
|
||||||
!options.some((t) => t.name.toLowerCase() === kw.toLowerCase())
|
|
||||||
) {
|
|
||||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
|
||||||
}
|
|
||||||
|
|
||||||
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
|
||||||
|
|
||||||
// 4) 最终结果
|
|
||||||
return [defaultOption, ...options]
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => {
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
if (v.includes(0)) {
|
|
||||||
emit('update:modelValue', [])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (v.length > 2) {
|
|
||||||
toast.error('最多选择两个标签')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
v = v.map((id) => {
|
|
||||||
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
|
||||||
const name = id.slice(11)
|
|
||||||
const newId = `__new__:${name}`
|
|
||||||
if (!localTags.value.find((t) => t.id === newId)) {
|
|
||||||
localTags.value.push({ id: newId, name })
|
|
||||||
}
|
|
||||||
return newId
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
})
|
|
||||||
}
|
|
||||||
emit('update:modelValue', v)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return { fetchTags, selected, isImageIcon, mergedOptions }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildTagsUrl = (kw = '') => {
|
||||||
|
const base = API_BASE_URL || (process.client ? window.location.origin : '')
|
||||||
|
const url = new URL('/api/tags', base)
|
||||||
|
|
||||||
|
if (kw) url.searchParams.set('keyword', kw)
|
||||||
|
url.searchParams.set('limit', '10')
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTags = async (kw = '') => {
|
||||||
|
const defaultOption = { id: 0, name: '无标签' }
|
||||||
|
|
||||||
|
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||||
|
const url = buildTagsUrl(kw)
|
||||||
|
|
||||||
|
// 2) 拉数据
|
||||||
|
let data = []
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) data = await res.json()
|
||||||
|
} catch {
|
||||||
|
toast.error('获取标签失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 合并、去重、可创建
|
||||||
|
let options = [...data, ...localTags.value]
|
||||||
|
|
||||||
|
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||||
|
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
||||||
|
}
|
||||||
|
|
||||||
|
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
||||||
|
|
||||||
|
// 4) 最终结果
|
||||||
|
return [defaultOption, ...options]
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
if (v.includes(0)) {
|
||||||
|
emit('update:modelValue', [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (v.length > 2) {
|
||||||
|
toast.error('最多选择两个标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v = v.map((id) => {
|
||||||
|
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
||||||
|
const name = id.slice(11)
|
||||||
|
const newId = `__new__:${name}`
|
||||||
|
if (!localTags.value.find((t) => t.id === newId)) {
|
||||||
|
localTags.value.push({ id: newId, name })
|
||||||
|
}
|
||||||
|
return newId
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
emit('update:modelValue', v)
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -11,20 +11,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'UserList',
|
users: { type: Array, default: () => [] },
|
||||||
components: { BasePlaceholder },
|
})
|
||||||
props: {
|
|
||||||
users: { type: Array, default: () => [] },
|
const handleUserClick = (user) => {
|
||||||
},
|
navigateTo(`/users/${user.id}`, { replace: true })
|
||||||
methods: {
|
|
||||||
handleUserClick(user) {
|
|
||||||
this.$router.push(`/users/${user.id}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'
|
|
||||||
+4
-10
@@ -1,11 +1,5 @@
|
|||||||
export const API_BASE_URL = 'https://www.open-isle.com'
|
|
||||||
// export const API_BASE_URL = 'http://127.0.0.1:8081'
|
|
||||||
// export const API_BASE_URL = 'http://30.211.97.238:8081'
|
|
||||||
export const GOOGLE_CLIENT_ID =
|
|
||||||
'777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
|
|
||||||
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
|
|
||||||
export const DISCORD_CLIENT_ID = '1394985417044000779'
|
|
||||||
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
|
|
||||||
|
|
||||||
// 重新导出 toast 功能,使用 composable 方式
|
|
||||||
export { toast } from './composables/useToast'
|
export { toast } from './composables/useToast'
|
||||||
|
|
||||||
|
export const API_DOMAIN = 'https://www.open-isle.com'
|
||||||
|
export const API_PORT = ''
|
||||||
|
export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN
|
||||||
|
|||||||
@@ -2,9 +2,20 @@ import { defineNuxtConfig } from 'nuxt/config'
|
|||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
ssr: true,
|
ssr: true,
|
||||||
// Ensure Vditor styles load before our overrides in global.css
|
runtimeConfig: {
|
||||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
public: {
|
||||||
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
|
||||||
|
websiteBaseUrl: process.env.NUXT_PUBLIC_WEBSITE_BASE_URL || '',
|
||||||
|
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',
|
||||||
|
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
|
||||||
|
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
|
||||||
|
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||||
|
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||||
app: {
|
app: {
|
||||||
|
pageTransition: { name: 'page', mode: 'out-in' },
|
||||||
head: {
|
head: {
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
@@ -16,7 +27,31 @@ export default defineNuxtConfig({
|
|||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
|
let themeColor = '#fff';
|
||||||
|
let themeStatus = 'default';
|
||||||
|
if (theme === 'dark') {
|
||||||
|
themeColor = '#333';
|
||||||
|
themeStatus = 'black-translucent';
|
||||||
|
} else {
|
||||||
|
themeColor = '#ffffff';
|
||||||
|
themeStatus = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const androidMeta = document.createElement('meta');
|
||||||
|
androidMeta.name = 'theme-color';
|
||||||
|
androidMeta.content = themeColor;
|
||||||
|
|
||||||
|
const iosMeta = document.createElement('meta');
|
||||||
|
iosMeta.name = 'apple-mobile-web-app-status-bar-style';
|
||||||
|
iosMeta.content = themeStatus;
|
||||||
|
|
||||||
|
document.head.appendChild(androidMeta);
|
||||||
|
document.head.appendChild(iosMeta);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Theme initialization failed:', e);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
@@ -42,10 +77,35 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
baseURL: '/',
|
||||||
|
buildAssetsDir: '/_nuxt/',
|
||||||
},
|
},
|
||||||
vue: {
|
vue: {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+3232
-1718
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="point-mall-page">
|
||||||
|
<p v-if="authState.loggedIn && point !== null">我的积分:{{ point }}</p>
|
||||||
|
<p v-else>请先登录以查看积分</p>
|
||||||
|
|
||||||
|
<section class="rules">
|
||||||
|
<h2>积分规则</h2>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="goods">
|
||||||
|
<h2>积分兑换商品</h2>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(good, idx) in goods" :key="idx">{{ good.name }} - {{ good.cost }} 积分</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||||
|
|
||||||
|
const point = ref(null)
|
||||||
|
|
||||||
|
const pointRules = [
|
||||||
|
'发帖:每天前两次,每次 30 积分',
|
||||||
|
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
|
||||||
|
'帖子被点赞:每次 10 积分',
|
||||||
|
'评论被点赞:每次 10 积分',
|
||||||
|
]
|
||||||
|
|
||||||
|
const goods = [
|
||||||
|
{ name: 'GPT Plus for 1 month', cost: 20000 },
|
||||||
|
{ name: '奶茶', cost: 5000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
point.value = user ? user.point : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.point-mall-page {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules,
|
||||||
|
.goods {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,8 +41,9 @@ import { use } from 'echarts/core'
|
|||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { getToken } from '~/utils/auth'
|
import { getToken } from '~/utils/auth'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
||||||
|
|
||||||
|
|||||||
@@ -29,35 +29,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const activities = ref([])
|
||||||
name: 'ActivityListPageView',
|
const isLoadingActivities = ref(false)
|
||||||
components: { MilkTeaActivityComponent },
|
|
||||||
data() {
|
onMounted(async () => {
|
||||||
return {
|
isLoadingActivities.value = true
|
||||||
activities: [],
|
try {
|
||||||
TimeManager,
|
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||||
isLoadingActivities: false,
|
if (res.ok) {
|
||||||
|
activities.value = await res.json()
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
async mounted() {
|
console.error(e)
|
||||||
this.isLoadingActivities = true
|
} finally {
|
||||||
try {
|
isLoadingActivities.value = false
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
}
|
||||||
if (res.ok) {
|
})
|
||||||
this.activities = await res.json()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
this.isLoadingActivities = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -2,24 +2,20 @@
|
|||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import CallbackPage from '~/components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { discordExchange } from '~/utils/discord'
|
import { discordExchange } from '~/utils/discord'
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'DiscordCallbackPageView',
|
const url = new URL(window.location.href)
|
||||||
components: { CallbackPage },
|
const code = url.searchParams.get('code')
|
||||||
async mounted() {
|
const state = url.searchParams.get('state')
|
||||||
const url = new URL(window.location.href)
|
const result = await discordExchange(code, state, '')
|
||||||
const code = url.searchParams.get('code')
|
|
||||||
const state = url.searchParams.get('state')
|
|
||||||
const result = await discordExchange(code, state, '')
|
|
||||||
|
|
||||||
if (result.needReason) {
|
if (result.needReason) {
|
||||||
this.$router.push('/signup-reason?token=' + result.token)
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
this.$router.push('/')
|
navigateTo('/', { replace: true })
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="forgot-page">
|
<div class="forgot-page">
|
||||||
<div class="forgot-content">
|
<div class="forgot-content">
|
||||||
<div class="forgot-title">找回密码</div>
|
<div class="forgot-title">找回密码</div>
|
||||||
|
|
||||||
<div v-if="step === 0" class="step-content">
|
<div v-if="step === 0" class="step-content">
|
||||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||||
@@ -19,109 +20,110 @@
|
|||||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||||
<div class="primary-button disabled" v-else>提交中...</div>
|
<div class="primary-button disabled" v-else>提交中...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint-message">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
export default {
|
import { useRoute } from 'vue-router'
|
||||||
name: 'ForgotPasswordPageView',
|
|
||||||
components: { BaseInput },
|
const config = useRuntimeConfig()
|
||||||
data() {
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
return {
|
|
||||||
step: 0,
|
const step = ref(0)
|
||||||
email: '',
|
const email = ref('')
|
||||||
code: '',
|
const code = ref('')
|
||||||
password: '',
|
const password = ref('')
|
||||||
token: '',
|
const token = ref('')
|
||||||
emailError: '',
|
const emailError = ref('')
|
||||||
passwordError: '',
|
const passwordError = ref('')
|
||||||
isSending: false,
|
const isSending = ref(false)
|
||||||
isVerifying: false,
|
const isVerifying = ref(false)
|
||||||
isResetting: false,
|
const isResetting = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.email) {
|
||||||
|
email.value = decodeURIComponent(route.query.email)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const sendCode = async () => {
|
||||||
|
if (!email.value) {
|
||||||
|
emailError.value = '邮箱不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
isSending.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.value }),
|
||||||
|
})
|
||||||
|
isSending.value = false
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('验证码已发送')
|
||||||
|
step.value = 1
|
||||||
|
} else {
|
||||||
|
toast.error('请填写已注册邮箱')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
mounted() {
|
isSending.value = false
|
||||||
if (this.$route.query.email) {
|
toast.error('发送失败')
|
||||||
this.email = decodeURIComponent(this.$route.query.email)
|
}
|
||||||
|
}
|
||||||
|
const verifyCode = async () => {
|
||||||
|
try {
|
||||||
|
isVerifying.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.value, code: code.value }),
|
||||||
|
})
|
||||||
|
isVerifying.value = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
token.value = data.token
|
||||||
|
step.value = 2
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '验证失败')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
methods: {
|
isVerifying.value = false
|
||||||
async sendCode() {
|
toast.error('验证失败')
|
||||||
if (!this.email) {
|
}
|
||||||
this.emailError = '邮箱不能为空'
|
}
|
||||||
return
|
const resetPassword = async () => {
|
||||||
}
|
if (!password.value) {
|
||||||
try {
|
passwordError.value = '密码不能为空'
|
||||||
this.isSending = true
|
return
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
}
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
isResetting.value = true
|
||||||
body: JSON.stringify({ email: this.email }),
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
||||||
})
|
method: 'POST',
|
||||||
this.isSending = false
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (res.ok) {
|
body: JSON.stringify({ token: token.value, password: password.value }),
|
||||||
toast.success('验证码已发送')
|
})
|
||||||
this.step = 1
|
isResetting.value = false
|
||||||
} else {
|
const data = await res.json()
|
||||||
toast.error('请填写已注册邮箱')
|
if (res.ok) {
|
||||||
}
|
toast.success('密码已重置')
|
||||||
} catch (e) {
|
navigateTo('/login', { replace: true })
|
||||||
this.isSending = false
|
} else if (data.field === 'password') {
|
||||||
toast.error('发送失败')
|
passwordError.value = data.error
|
||||||
}
|
} else {
|
||||||
},
|
toast.error(data.error || '重置失败')
|
||||||
async verifyCode() {
|
}
|
||||||
try {
|
} catch (e) {
|
||||||
this.isVerifying = true
|
isResetting.value = false
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
toast.error('重置失败')
|
||||||
method: 'POST',
|
}
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: this.email, code: this.code }),
|
|
||||||
})
|
|
||||||
this.isVerifying = false
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok) {
|
|
||||||
this.token = data.token
|
|
||||||
this.step = 2
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '验证失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.isVerifying = false
|
|
||||||
toast.error('验证失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async resetPassword() {
|
|
||||||
if (!this.password) {
|
|
||||||
this.passwordError = '密码不能为空'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.isResetting = true
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: this.token, password: this.password }),
|
|
||||||
})
|
|
||||||
this.isResetting = false
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok) {
|
|
||||||
toast.success('密码已重置')
|
|
||||||
this.$router.push('/login')
|
|
||||||
} else if (data.field === 'password') {
|
|
||||||
this.passwordError = data.error
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '重置失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.isResetting = false
|
|
||||||
toast.error('重置失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -143,6 +145,21 @@ export default {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forgot-content .hint-message {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--blockquote-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-message i {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
.step-content {
|
.step-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,24 +2,20 @@
|
|||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import CallbackPage from '~/components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { githubExchange } from '~/utils/github'
|
import { githubExchange } from '~/utils/github'
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'GithubCallbackPageView',
|
const url = new URL(window.location.href)
|
||||||
components: { CallbackPage },
|
const code = url.searchParams.get('code')
|
||||||
async mounted() {
|
const state = url.searchParams.get('state')
|
||||||
const url = new URL(window.location.href)
|
const result = await githubExchange(code, state, '')
|
||||||
const code = url.searchParams.get('code')
|
|
||||||
const state = url.searchParams.get('state')
|
|
||||||
const result = await githubExchange(code, state, '')
|
|
||||||
|
|
||||||
if (result.needReason) {
|
if (result.needReason) {
|
||||||
this.$router.push('/signup-reason?token=' + result.token)
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
this.$router.push('/')
|
navigateTo('/', { replace: true })
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,29 +2,25 @@
|
|||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import CallbackPage from '~/components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { googleAuthWithToken } from '~/utils/google'
|
import { googleAuthWithToken } from '~/utils/google'
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'GoogleCallbackPageView',
|
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||||
components: { CallbackPage },
|
const idToken = hash.get('id_token')
|
||||||
async mounted() {
|
if (idToken) {
|
||||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
await googleAuthWithToken(
|
||||||
const idToken = hash.get('id_token')
|
idToken,
|
||||||
if (idToken) {
|
() => {
|
||||||
await googleAuthWithToken(
|
navigateTo('/', { replace: true })
|
||||||
idToken,
|
},
|
||||||
() => {
|
(token) => {
|
||||||
this.$router.push('/')
|
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||||
},
|
},
|
||||||
(token) => {
|
)
|
||||||
this.$router.push('/signup-reason?token=' + token)
|
} else {
|
||||||
},
|
navigateTo('/login', { replace: true })
|
||||||
)
|
}
|
||||||
} else {
|
})
|
||||||
this.$router.push('/login')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+259
-328
@@ -1,10 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<div v-if="!isMobile" class="search-container">
|
<div v-if="!isMobile" class="search-container">
|
||||||
<div class="search-title">一切可能,从此刻启航</div>
|
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
||||||
<div class="search-subtitle">
|
|
||||||
愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。
|
|
||||||
</div>
|
|
||||||
<SearchDropdown />
|
<SearchDropdown />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
|
<div v-if="pendingFirst" class="loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,7 +57,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-item" v-for="article in articles" :key="article.id">
|
<div
|
||||||
|
v-if="!pendingFirst"
|
||||||
|
class="article-item"
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
>
|
||||||
<div class="article-main-container">
|
<div class="article-main-container">
|
||||||
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||||
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||||
@@ -104,343 +106,274 @@
|
|||||||
热门帖子功能开发中,敬请期待。
|
热门帖子功能开发中,敬请期待。
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||||
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
<div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<script setup>
|
||||||
<script>
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { getToken } from '~/utils/auth'
|
import { getToken } from '~/utils/auth'
|
||||||
import { useScrollLoadMore } from '~/utils/loadMore'
|
import { useScrollLoadMore } from '~/utils/loadMore'
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
export default {
|
useHead({
|
||||||
name: 'HomePageView',
|
title: 'OpenIsle - 全面开源的自由社区',
|
||||||
components: {
|
meta: [
|
||||||
CategorySelect,
|
{
|
||||||
TagSelect,
|
name: 'description',
|
||||||
ArticleTags,
|
content:
|
||||||
ArticleCategory,
|
'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
|
||||||
SearchDropdown,
|
},
|
||||||
ClientOnly: () =>
|
],
|
||||||
import('vue').then((m) =>
|
})
|
||||||
m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
async setup() {
|
|
||||||
useHead({
|
|
||||||
title: 'OpenIsle - 全面开源的自由社区',
|
|
||||||
meta: [
|
|
||||||
{
|
|
||||||
name: 'description',
|
|
||||||
content:
|
|
||||||
'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const selectedCategory = ref('')
|
|
||||||
const selectedTags = ref([])
|
|
||||||
const route = useRoute()
|
|
||||||
const tagOptions = ref([])
|
|
||||||
const categoryOptions = ref([])
|
|
||||||
const isLoadingPosts = ref(false)
|
|
||||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
|
||||||
const selectedTopic = ref(
|
|
||||||
route.query.view === 'ranking'
|
|
||||||
? '排行榜'
|
|
||||||
: route.query.view === 'latest'
|
|
||||||
? '最新'
|
|
||||||
: '最新回复',
|
|
||||||
)
|
|
||||||
const articles = ref([])
|
|
||||||
const page = ref(0)
|
|
||||||
const pageSize = 10
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const allLoaded = ref(false)
|
|
||||||
|
|
||||||
const selectedCategorySet = (category) => {
|
const config = useRuntimeConfig()
|
||||||
const c = decodeURIComponent(category)
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
selectedCategory.value = isNaN(c) ? c : Number(c)
|
const selectedCategory = ref('')
|
||||||
}
|
const selectedTags = ref([])
|
||||||
|
const route = useRoute()
|
||||||
|
const tagOptions = ref([])
|
||||||
|
const categoryOptions = ref([])
|
||||||
|
|
||||||
const selectedTagsSet = (tags) => {
|
const isLoadingMore = ref(false)
|
||||||
const t = Array.isArray(tags) ? tags.join(',') : tags
|
|
||||||
selectedTags.value = t
|
|
||||||
.split(',')
|
|
||||||
.filter((v) => v)
|
|
||||||
.map((v) => decodeURIComponent(v))
|
|
||||||
.map((v) => (isNaN(v) ? v : Number(v)))
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||||
const query = route.query
|
const selectedTopicCookie = useCookie('homeTab')
|
||||||
const category = query.category
|
const selectedTopic = ref(
|
||||||
const tags = query.tags
|
selectedTopicCookie.value
|
||||||
|
? selectedTopicCookie.value
|
||||||
|
: route.query.view === 'ranking'
|
||||||
|
? '排行榜'
|
||||||
|
: route.query.view === 'latest'
|
||||||
|
? '最新'
|
||||||
|
: '最新回复',
|
||||||
|
)
|
||||||
|
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||||
|
const articles = ref([])
|
||||||
|
const page = ref(0)
|
||||||
|
const pageSize = 10
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const allLoaded = ref(false)
|
||||||
|
|
||||||
if (category) {
|
/** URL 参数 -> 本地筛选值 **/
|
||||||
selectedCategorySet(category)
|
const selectedCategorySet = (category) => {
|
||||||
}
|
const c = decodeURIComponent(category)
|
||||||
if (tags) {
|
selectedCategory.value = isNaN(c) ? c : Number(c)
|
||||||
selectedTagsSet(tags)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.query,
|
|
||||||
() => {
|
|
||||||
const query = route.query
|
|
||||||
const category = query.category
|
|
||||||
const tags = query.tags
|
|
||||||
category && selectedCategorySet(category)
|
|
||||||
tags && selectedTagsSet(tags)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const loadOptions = async () => {
|
|
||||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
|
||||||
if (res.ok) {
|
|
||||||
categoryOptions.value = [await res.json()]
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
const arr = []
|
|
||||||
for (const t of selectedTags.value) {
|
|
||||||
if (!isNaN(t)) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
|
||||||
if (r.ok) arr.push(await r.json())
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tagOptions.value = arr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildUrl = () => {
|
|
||||||
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
selectedTags.value.forEach((t) => {
|
|
||||||
url += `&tagIds=${t}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildRankUrl = () => {
|
|
||||||
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
selectedTags.value.forEach((t) => {
|
|
||||||
url += `&tagIds=${t}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildReplyUrl = () => {
|
|
||||||
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
selectedTags.value.forEach((t) => {
|
|
||||||
url += `&tagIds=${t}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPosts = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchRanking = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildRankUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchLatestReply = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildReplyUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchContent = async (reset = false) => {
|
|
||||||
if (selectedTopic.value === '排行榜') {
|
|
||||||
await fetchRanking(reset)
|
|
||||||
} else if (selectedTopic.value === '最新回复') {
|
|
||||||
await fetchLatestReply(reset)
|
|
||||||
} else {
|
|
||||||
await fetchPosts(reset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useScrollLoadMore(fetchContent)
|
|
||||||
|
|
||||||
watch([selectedCategory, selectedTags], () => {
|
|
||||||
fetchContent(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedTopic, () => {
|
|
||||||
fetchContent(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
const sanitizeDescription = (text) => stripMarkdown(text)
|
|
||||||
|
|
||||||
await Promise.all([loadOptions(), fetchContent()])
|
|
||||||
|
|
||||||
return {
|
|
||||||
topics,
|
|
||||||
selectedTopic,
|
|
||||||
articles,
|
|
||||||
sanitizeDescription,
|
|
||||||
isLoadingPosts,
|
|
||||||
selectedCategory,
|
|
||||||
selectedTags,
|
|
||||||
tagOptions,
|
|
||||||
categoryOptions,
|
|
||||||
isMobile,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const selectedTagsSet = (tags) => {
|
||||||
|
const t = Array.isArray(tags) ? tags.join(',') : tags
|
||||||
|
selectedTags.value = t
|
||||||
|
.split(',')
|
||||||
|
.filter((v) => v)
|
||||||
|
.map((v) => decodeURIComponent(v))
|
||||||
|
.map((v) => (isNaN(v) ? v : Number(v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
|
||||||
|
onMounted(() => {
|
||||||
|
const { category, tags } = route.query
|
||||||
|
if (category) selectedCategorySet(category)
|
||||||
|
if (tags) selectedTagsSet(tags)
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('homeTab')
|
||||||
|
if (saved) {
|
||||||
|
selectedTopic.value = saved
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 路由变更时同步筛选 **/
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(query) => {
|
||||||
|
const category = query.category
|
||||||
|
const tags = query.tags
|
||||||
|
category && selectedCategorySet(category)
|
||||||
|
tags && selectedTagsSet(tags)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 选项加载(分类/标签名称回填) **/
|
||||||
|
const loadOptions = async () => {
|
||||||
|
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/categories/`)
|
||||||
|
if (res.ok) categoryOptions.value = [await res.json()]
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length) {
|
||||||
|
const arr = []
|
||||||
|
for (const t of selectedTags.value) {
|
||||||
|
if (!isNaN(t)) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
||||||
|
if (r.ok) arr.push(await r.json())
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagOptions.value = arr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表 API 路径与查询参数 **/
|
||||||
|
const baseQuery = computed(() => ({
|
||||||
|
categoryId: selectedCategory.value || undefined,
|
||||||
|
tagIds: selectedTags.value.length ? selectedTags.value : undefined,
|
||||||
|
}))
|
||||||
|
const listApiPath = computed(() => {
|
||||||
|
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
|
||||||
|
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
|
||||||
|
return '/api/posts'
|
||||||
|
})
|
||||||
|
const buildUrl = ({ pageNo }) => {
|
||||||
|
const url = new URL(`${API_BASE_URL}${listApiPath.value}`)
|
||||||
|
url.searchParams.set('page', pageNo)
|
||||||
|
url.searchParams.set('pageSize', pageSize)
|
||||||
|
if (baseQuery.value.categoryId) url.searchParams.set('categoryId', baseQuery.value.categoryId)
|
||||||
|
if (baseQuery.value.tagIds)
|
||||||
|
for (const t of baseQuery.value.tagIds) url.searchParams.append('tagIds', t)
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
const tokenHeader = computed(() => {
|
||||||
|
const token = getToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** —— 首屏数据托管(SSR) —— **/
|
||||||
|
const asyncKey = computed(() => [
|
||||||
|
'home:firstpage',
|
||||||
|
selectedTopic.value,
|
||||||
|
String(baseQuery.value.categoryId ?? ''),
|
||||||
|
JSON.stringify(baseQuery.value.tagIds ?? []),
|
||||||
|
])
|
||||||
|
const {
|
||||||
|
data: firstPage,
|
||||||
|
pending: pendingFirst,
|
||||||
|
refresh: refreshFirst,
|
||||||
|
} = await useAsyncData(
|
||||||
|
() => asyncKey.value.join('::'),
|
||||||
|
async () => {
|
||||||
|
const res = await $fetch(buildUrl({ pageNo: 0 }), { headers: tokenHeader.value })
|
||||||
|
const data = Array.isArray(res) ? res : []
|
||||||
|
return data.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(
|
||||||
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
),
|
||||||
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
|
type: p.type,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
server: true,
|
||||||
|
default: () => [],
|
||||||
|
watch: [selectedTopic, baseQuery],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 首屏/筛选变更:重置分页并灌入 firstPage **/
|
||||||
|
watch(
|
||||||
|
firstPage,
|
||||||
|
(data) => {
|
||||||
|
page.value = 0
|
||||||
|
articles.value = [...(data || [])]
|
||||||
|
allLoaded.value = (data?.length || 0) < pageSize
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** —— 滚动加载更多 —— **/
|
||||||
|
let inflight = null
|
||||||
|
const fetchNextPage = async () => {
|
||||||
|
if (allLoaded.value || pendingFirst.value || inflight) return
|
||||||
|
const nextPage = page.value + 1
|
||||||
|
isLoadingMore.value = true
|
||||||
|
inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
|
||||||
|
.then((res) => {
|
||||||
|
const data = Array.isArray(res) ? res : []
|
||||||
|
const mapped = data.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(
|
||||||
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
),
|
||||||
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
|
type: p.type,
|
||||||
|
}))
|
||||||
|
articles.value.push(...mapped)
|
||||||
|
if (data.length < pageSize) {
|
||||||
|
allLoaded.value = true
|
||||||
|
} else {
|
||||||
|
page.value = nextPage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight = null
|
||||||
|
isLoadingMore.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定滚动加载(避免挂载瞬间触发) **/
|
||||||
|
let initialReady = false
|
||||||
|
const loadMoreGuarded = async () => {
|
||||||
|
if (!initialReady) return
|
||||||
|
await fetchNextPage()
|
||||||
|
}
|
||||||
|
useScrollLoadMore(loadMoreGuarded)
|
||||||
|
watch(
|
||||||
|
articles,
|
||||||
|
() => {
|
||||||
|
if (!initialReady && articles.value.length) initialReady = true
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 切换分类/标签/Tab:useAsyncData 已 watch,这里只需确保 options 加载 **/
|
||||||
|
watch([selectedCategory, selectedTags], () => {
|
||||||
|
loadOptions()
|
||||||
|
})
|
||||||
|
watch(selectedTopic, (val) => {
|
||||||
|
// 仅当需要额外选项时加载
|
||||||
|
loadOptions()
|
||||||
|
selectedTopicCookie.value = val
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.setItem('homeTab', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||||
|
if (import.meta.server) {
|
||||||
|
await loadOptions()
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||||
|
|
||||||
|
window.addEventListener('refresh-home', refreshFirst)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 其他工具函数 **/
|
||||||
|
const sanitizeDescription = (text) => stripMarkdown(text)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -454,8 +387,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
margin-top: 100px;
|
margin-top: 32px;
|
||||||
padding: 20px;
|
padding: 20px 20px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -467,10 +400,6 @@ export default {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-subtitle {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -505,6 +434,7 @@ export default {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-item-container {
|
.topic-item-container {
|
||||||
@@ -624,6 +554,7 @@ export default {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: gray;
|
color: gray;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
line-clamp: 3;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
>找回密码</a
|
>找回密码</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint-message">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,8 +55,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
import { setToken, loadCurrentUser } from '~/utils/auth'
|
||||||
import { googleAuthorize } from '~/utils/google'
|
import { googleAuthorize } from '~/utils/google'
|
||||||
import { githubAuthorize } from '~/utils/github'
|
import { githubAuthorize } from '~/utils/github'
|
||||||
@@ -60,63 +64,56 @@ import { discordAuthorize } from '~/utils/discord'
|
|||||||
import { twitterAuthorize } from '~/utils/twitter'
|
import { twitterAuthorize } from '~/utils/twitter'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { registerPush } from '~/utils/push'
|
import { registerPush } from '~/utils/push'
|
||||||
export default {
|
const config = useRuntimeConfig()
|
||||||
name: 'LoginPageView',
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
components: { BaseInput },
|
const username = ref('')
|
||||||
setup() {
|
const password = ref('')
|
||||||
return { googleAuthorize }
|
const isWaitingForLogin = ref(false)
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
isWaitingForLogin: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async submitLogin() {
|
|
||||||
try {
|
|
||||||
this.isWaitingForLogin = true
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username: this.username, password: this.password }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok && data.token) {
|
|
||||||
setToken(data.token)
|
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
|
||||||
registerPush()
|
|
||||||
this.$router.push('/')
|
|
||||||
} else if (data.reason_code === 'NOT_VERIFIED') {
|
|
||||||
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
|
||||||
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } })
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
|
||||||
toast.info('您的注册正在审批中, 请留意邮件')
|
|
||||||
this.$router.push('/')
|
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
|
||||||
this.$router.push('/signup-reason?token=' + data.token)
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '登录失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('登录失败')
|
|
||||||
} finally {
|
|
||||||
this.isWaitingForLogin = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
loginWithGithub() {
|
const submitLogin = async () => {
|
||||||
githubAuthorize()
|
try {
|
||||||
},
|
isWaitingForLogin.value = true
|
||||||
loginWithDiscord() {
|
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||||
discordAuthorize()
|
method: 'POST',
|
||||||
},
|
headers: { 'Content-Type': 'application/json' },
|
||||||
loginWithTwitter() {
|
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||||
twitterAuthorize()
|
})
|
||||||
},
|
const data = await res.json()
|
||||||
},
|
if (res.ok && data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
|
toast.success('登录成功')
|
||||||
|
registerPush()
|
||||||
|
await navigateTo('/', { replace: true })
|
||||||
|
} else if (data.reason_code === 'NOT_VERIFIED') {
|
||||||
|
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
||||||
|
await navigateTo(
|
||||||
|
{ path: '/signup', query: { verify: '1', u: username.value } },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册正在审批中, 请留意邮件')
|
||||||
|
await navigateTo('/', { replace: true })
|
||||||
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
|
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('登录失败')
|
||||||
|
} finally {
|
||||||
|
isWaitingForLogin.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginWithGithub = () => {
|
||||||
|
githubAuthorize()
|
||||||
|
}
|
||||||
|
const loginWithDiscord = () => {
|
||||||
|
discordAuthorize()
|
||||||
|
}
|
||||||
|
const loginWithTwitter = () => {
|
||||||
|
twitterAuthorize()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -266,6 +263,11 @@ export default {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint-message {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.login-page {
|
.login-page {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+127
-347
@@ -198,6 +198,19 @@
|
|||||||
中获奖
|
中获奖
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'LOTTERY_DRAW'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
您的抽奖贴
|
||||||
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
已开奖
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
@@ -491,361 +504,125 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import NotificationContainer from '~/components/NotificationContainer.vue'
|
import NotificationContainer from '~/components/NotificationContainer.vue'
|
||||||
import { getToken, authState } from '~/utils/auth'
|
|
||||||
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
|
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
fetchUnreadCount,
|
||||||
|
isLoadingMessage,
|
||||||
|
markRead,
|
||||||
|
notifications,
|
||||||
|
markAllRead,
|
||||||
|
} from '~/utils/notification'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import { reactionEmojiMap } from '~/utils/reactions'
|
|
||||||
|
|
||||||
export default {
|
const config = useRuntimeConfig()
|
||||||
name: 'MessagePageView',
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
|
const route = useRoute()
|
||||||
setup() {
|
const selectedTab = ref(
|
||||||
const router = useRouter()
|
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
||||||
const route = useRoute()
|
)
|
||||||
const notifications = ref([])
|
const notificationPrefs = ref([])
|
||||||
const isLoadingMessage = ref(false)
|
const filteredNotifications = computed(() =>
|
||||||
const selectedTab = ref(
|
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
|
||||||
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
)
|
||||||
)
|
|
||||||
const notificationPrefs = ref([])
|
|
||||||
const filteredNotifications = computed(() =>
|
|
||||||
selectedTab.value === 'all'
|
|
||||||
? notifications.value
|
|
||||||
: notifications.value.filter((n) => !n.read),
|
|
||||||
)
|
|
||||||
|
|
||||||
const markRead = async (id) => {
|
const fetchPrefs = async () => {
|
||||||
if (!id) return
|
notificationPrefs.value = await fetchNotificationPreferences()
|
||||||
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',
|
|
||||||
MENTION: 'fas fa-at',
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isLoadingMessage.value = true
|
|
||||||
notifications.value = []
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingMessage.value = false
|
|
||||||
if (!res.ok) {
|
|
||||||
toast.error('获取通知失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
for (const n of data) {
|
|
||||||
if (n.type === 'COMMENT_REPLY') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.comment.author.avatar,
|
|
||||||
iconClick: () => {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'REACTION') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
emoji: reactionEmojiMap[n.reactionType],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'POST_VIEWED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.fromUser ? n.fromUser.avatar : null,
|
|
||||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === '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 === 'POST_UPDATED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.comment.author.avatar,
|
|
||||||
iconClick: () => {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'USER_ACTIVITY') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.comment.author.avatar,
|
|
||||||
iconClick: () => {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'MENTION') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'FOLLOWED_POST') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.post) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/posts/${n.post.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.post) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/posts/${n.post.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.fromUser ? n.fromUser.avatar : null,
|
|
||||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.post) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/posts/${n.post.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'REGISTER_REQUEST') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPrefs = async () => {
|
|
||||||
notificationPrefs.value = await fetchNotificationPreferences()
|
|
||||||
}
|
|
||||||
|
|
||||||
const togglePref = async (pref) => {
|
|
||||||
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
|
|
||||||
if (ok) {
|
|
||||||
pref.enabled = !pref.enabled
|
|
||||||
await fetchNotifications()
|
|
||||||
await fetchUnreadCount()
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const approve = async (id, nid) => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) return
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
markRead(nid)
|
|
||||||
toast.success('已同意')
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reject = async (id, nid) => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) return
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
markRead(nid)
|
|
||||||
toast.success('已拒绝')
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatType = (t) => {
|
|
||||||
switch (t) {
|
|
||||||
case 'POST_VIEWED':
|
|
||||||
return '帖子被查看'
|
|
||||||
case 'COMMENT_REPLY':
|
|
||||||
return '有人回复了你'
|
|
||||||
case 'REACTION':
|
|
||||||
return '有人点赞'
|
|
||||||
case 'POST_REVIEW_REQUEST':
|
|
||||||
return '帖子待审核'
|
|
||||||
case 'POST_REVIEWED':
|
|
||||||
return '帖子审核结果'
|
|
||||||
case 'POST_UPDATED':
|
|
||||||
return '关注的帖子有新评论'
|
|
||||||
case 'FOLLOWED_POST':
|
|
||||||
return '关注的用户发布了新文章'
|
|
||||||
case 'POST_SUBSCRIBED':
|
|
||||||
return '有人订阅了你的文章'
|
|
||||||
case 'POST_UNSUBSCRIBED':
|
|
||||||
return '有人取消订阅你的文章'
|
|
||||||
case 'USER_FOLLOWED':
|
|
||||||
return '有人关注了你'
|
|
||||||
case 'USER_UNFOLLOWED':
|
|
||||||
return '有人取消关注你'
|
|
||||||
case 'USER_ACTIVITY':
|
|
||||||
return '关注的用户有新动态'
|
|
||||||
case 'MENTION':
|
|
||||||
return '有人提到了你'
|
|
||||||
case 'REGISTER_REQUEST':
|
|
||||||
return '有人申请注册'
|
|
||||||
case 'ACTIVITY_REDEEM':
|
|
||||||
return '有人申请兑换奶茶'
|
|
||||||
case 'LOTTERY_WIN':
|
|
||||||
return '抽奖中奖了'
|
|
||||||
default:
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchNotifications()
|
|
||||||
fetchPrefs()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
notifications,
|
|
||||||
formatType,
|
|
||||||
isLoadingMessage,
|
|
||||||
stripMarkdownLength,
|
|
||||||
markRead,
|
|
||||||
approve,
|
|
||||||
reject,
|
|
||||||
TimeManager,
|
|
||||||
selectedTab,
|
|
||||||
filteredNotifications,
|
|
||||||
markAllRead,
|
|
||||||
authState,
|
|
||||||
notificationPrefs,
|
|
||||||
togglePref,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const togglePref = async (pref) => {
|
||||||
|
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
|
||||||
|
if (ok) {
|
||||||
|
pref.enabled = !pref.enabled
|
||||||
|
await fetchNotifications()
|
||||||
|
await fetchUnreadCount()
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const approve = async (id, nid) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
markRead(nid)
|
||||||
|
toast.success('已同意')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reject = async (id, nid) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
markRead(nid)
|
||||||
|
toast.success('已拒绝')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatType = (t) => {
|
||||||
|
switch (t) {
|
||||||
|
case 'POST_VIEWED':
|
||||||
|
return '帖子被查看'
|
||||||
|
case 'COMMENT_REPLY':
|
||||||
|
return '有人回复了你'
|
||||||
|
case 'REACTION':
|
||||||
|
return '有人点赞'
|
||||||
|
case 'POST_REVIEW_REQUEST':
|
||||||
|
return '帖子待审核'
|
||||||
|
case 'POST_REVIEWED':
|
||||||
|
return '帖子审核结果'
|
||||||
|
case 'POST_UPDATED':
|
||||||
|
return '关注的帖子有新评论'
|
||||||
|
case 'FOLLOWED_POST':
|
||||||
|
return '关注的用户发布了新文章'
|
||||||
|
case 'POST_SUBSCRIBED':
|
||||||
|
return '有人订阅了你的文章'
|
||||||
|
case 'POST_UNSUBSCRIBED':
|
||||||
|
return '有人取消订阅你的文章'
|
||||||
|
case 'USER_FOLLOWED':
|
||||||
|
return '有人关注了你'
|
||||||
|
case 'USER_UNFOLLOWED':
|
||||||
|
return '有人取消关注你'
|
||||||
|
case 'USER_ACTIVITY':
|
||||||
|
return '关注的用户有新动态'
|
||||||
|
case 'MENTION':
|
||||||
|
return '有人提到了你'
|
||||||
|
case 'REGISTER_REQUEST':
|
||||||
|
return '有人申请注册'
|
||||||
|
case 'ACTIVITY_REDEEM':
|
||||||
|
return '有人申请兑换奶茶'
|
||||||
|
case 'LOTTERY_WIN':
|
||||||
|
return '抽奖中奖了'
|
||||||
|
case 'LOTTERY_DRAW':
|
||||||
|
return '抽奖已开奖'
|
||||||
|
default:
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
fetchNotifications()
|
||||||
|
fetchPrefs()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -859,6 +636,8 @@ export default {
|
|||||||
.message-page {
|
.message-page {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
height: calc(100vh - var(--header-height));
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-page-header {
|
.message-page-header {
|
||||||
@@ -870,6 +649,7 @@ export default {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-page-header-right {
|
.message-page-header-right {
|
||||||
|
|||||||
+274
-310
@@ -77,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import FlatPickr from 'vue-flatpickr-component'
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
@@ -88,335 +88,299 @@ import LoginOverlay from '~/components/LoginOverlay.vue'
|
|||||||
import PostEditor from '~/components/PostEditor.vue'
|
import PostEditor from '~/components/PostEditor.vue'
|
||||||
import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const title = ref('')
|
||||||
name: 'NewPostPageView',
|
const content = ref('')
|
||||||
components: {
|
const selectedCategory = ref('')
|
||||||
PostEditor,
|
const selectedTags = ref([])
|
||||||
CategorySelect,
|
const postType = ref('NORMAL')
|
||||||
TagSelect,
|
const prizeIcon = ref('')
|
||||||
LoginOverlay,
|
const prizeIconFile = ref(null)
|
||||||
PostTypeSelect,
|
const tempPrizeIcon = ref('')
|
||||||
AvatarCropper,
|
const showPrizeCropper = ref(false)
|
||||||
FlatPickr,
|
const prizeName = ref('')
|
||||||
},
|
const prizeCount = ref(1)
|
||||||
setup() {
|
const prizeDescription = ref('')
|
||||||
const title = ref('')
|
const endTime = ref(null)
|
||||||
const content = ref('')
|
const startTime = ref(null)
|
||||||
const selectedCategory = ref('')
|
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||||
const selectedTags = ref([])
|
const isWaitingPosting = ref(false)
|
||||||
const postType = ref('NORMAL')
|
const isAiLoading = ref(false)
|
||||||
const prizeIcon = ref('')
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
const prizeIconFile = ref(null)
|
|
||||||
const tempPrizeIcon = ref('')
|
|
||||||
const showPrizeCropper = ref(false)
|
|
||||||
const prizeName = ref('')
|
|
||||||
const prizeCount = ref(1)
|
|
||||||
const prizeDescription = ref('')
|
|
||||||
const endTime = ref(null)
|
|
||||||
const startTime = ref(null)
|
|
||||||
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
|
||||||
const isWaitingPosting = ref(false)
|
|
||||||
const isAiLoading = ref(false)
|
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
|
||||||
|
|
||||||
const onPrizeIconChange = (e) => {
|
const onPrizeIconChange = (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (file) {
|
if (file) {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
tempPrizeIcon.value = reader.result
|
tempPrizeIcon.value = reader.result
|
||||||
showPrizeCropper.value = true
|
showPrizeCropper.value = true
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onPrizeCropped = ({ file, url }) => {
|
const onPrizeCropped = ({ file, url }) => {
|
||||||
prizeIconFile.value = file
|
prizeIconFile.value = file
|
||||||
prizeIcon.value = url
|
prizeIcon.value = url
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(prizeCount, (val) => {
|
watch(prizeCount, (val) => {
|
||||||
if (!val || val < 1) prizeCount.value = 1
|
if (!val || val < 1) prizeCount.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadDraft = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
|
if (res.ok && res.status !== 204) {
|
||||||
|
const data = await res.json()
|
||||||
|
title.value = data.title || ''
|
||||||
|
content.value = data.content || ''
|
||||||
|
selectedCategory.value = data.categoryId || ''
|
||||||
|
selectedTags.value = data.tagIds || []
|
||||||
|
|
||||||
const loadDraft = async () => {
|
toast.success('草稿已加载')
|
||||||
const token = getToken()
|
|
||||||
if (!token) return
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok && res.status !== 204) {
|
|
||||||
const data = await res.json()
|
|
||||||
title.value = data.title || ''
|
|
||||||
content.value = data.content || ''
|
|
||||||
selectedCategory.value = data.categoryId || ''
|
|
||||||
selectedTags.value = data.tagIds || []
|
|
||||||
|
|
||||||
toast.success('草稿已加载')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadDraft)
|
onMounted(loadDraft)
|
||||||
|
|
||||||
const clearPost = async () => {
|
const clearPost = async () => {
|
||||||
title.value = ''
|
title.value = ''
|
||||||
content.value = ''
|
content.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
postType.value = 'NORMAL'
|
postType.value = 'NORMAL'
|
||||||
prizeIcon.value = ''
|
prizeIcon.value = ''
|
||||||
prizeIconFile.value = null
|
prizeIconFile.value = null
|
||||||
tempPrizeIcon.value = ''
|
tempPrizeIcon.value = ''
|
||||||
showPrizeCropper.value = false
|
showPrizeCropper.value = false
|
||||||
prizeDescription.value = ''
|
prizeDescription.value = ''
|
||||||
prizeCount.value = 1
|
prizeCount.value = 1
|
||||||
endTime.value = null
|
endTime.value = null
|
||||||
startTime.value = null
|
startTime.value = null
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (token) {
|
if (token) {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('草稿已清空')
|
toast.success('草稿已清空')
|
||||||
} else {
|
} else {
|
||||||
toast.error('云端草稿清空失败, 请稍后重试')
|
toast.error('云端草稿清空失败, 请稍后重试')
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveDraft = async () => {
|
const saveDraft = async () => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const tagIds = selectedTags.value.filter((t) => typeof t === 'number')
|
const tagIds = selectedTags.value.filter((t) => typeof t === 'number')
|
||||||
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
|
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title.value,
|
title: title.value,
|
||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value || null,
|
categoryId: selectedCategory.value || null,
|
||||||
tagIds,
|
tagIds,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('草稿已保存')
|
toast.success('草稿已保存')
|
||||||
} else {
|
} else {
|
||||||
toast.error('保存失败')
|
toast.error('保存失败')
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('保存失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const ensureTags = async (token) => {
|
} catch (e) {
|
||||||
for (let i = 0; i < selectedTags.value.length; i++) {
|
toast.error('保存失败')
|
||||||
const t = selectedTags.value[i]
|
}
|
||||||
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
}
|
||||||
const name = t.slice(8)
|
const ensureTags = async (token) => {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
for (let i = 0; i < selectedTags.value.length; i++) {
|
||||||
method: 'POST',
|
const t = selectedTags.value[i]
|
||||||
headers: {
|
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
||||||
'Content-Type': 'application/json',
|
const name = t.slice(8)
|
||||||
Authorization: `Bearer ${token}`,
|
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
||||||
},
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, description: '' }),
|
headers: {
|
||||||
})
|
'Content-Type': 'application/json',
|
||||||
if (res.ok) {
|
Authorization: `Bearer ${token}`,
|
||||||
const data = await res.json()
|
},
|
||||||
selectedTags.value[i] = data.id
|
body: JSON.stringify({ name, description: '' }),
|
||||||
// update local TagSelect options handled by component
|
})
|
||||||
} else {
|
if (res.ok) {
|
||||||
let data
|
|
||||||
try {
|
|
||||||
data = await res.json()
|
|
||||||
} catch (e) {
|
|
||||||
data = null
|
|
||||||
}
|
|
||||||
toast.error((data && data.error) || '创建标签失败')
|
|
||||||
throw new Error('create tag failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiGenerate = async () => {
|
|
||||||
if (!content.value.trim()) {
|
|
||||||
toast.error('内容为空,无法优化')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isAiLoading.value = true
|
|
||||||
try {
|
|
||||||
toast.info('AI 优化中...')
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ text: content.value }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
content.value = data.content || ''
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
toast.error('今日AI优化次数已用尽')
|
|
||||||
} else {
|
|
||||||
toast.error('AI 优化失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('AI 优化失败')
|
|
||||||
} finally {
|
|
||||||
isAiLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitPost = async () => {
|
|
||||||
if (!title.value.trim()) {
|
|
||||||
toast.error('标题不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!content.value.trim()) {
|
|
||||||
toast.error('内容不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!selectedCategory.value) {
|
|
||||||
toast.error('请选择分类')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length === 0) {
|
|
||||||
toast.error('请选择标签')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (postType.value === 'LOTTERY') {
|
|
||||||
if (!prizeIcon.value) {
|
|
||||||
toast.error('请上传奖品图片')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!prizeCount.value || prizeCount.value < 1) {
|
|
||||||
toast.error('奖品数量必须大于0')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!prizeDescription.value) {
|
|
||||||
toast.error('请输入奖品描述')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!endTime.value) {
|
|
||||||
toast.error('请选择抽奖结束时间')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
await ensureTags(token)
|
|
||||||
isWaitingPosting.value = true
|
|
||||||
let prizeIconUrl = prizeIcon.value
|
|
||||||
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', prizeIconFile.value)
|
|
||||||
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
const uploadData = await uploadRes.json()
|
|
||||||
if (!uploadRes.ok || uploadData.code !== 0) {
|
|
||||||
toast.error('奖品图片上传失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prizeIconUrl = uploadData.data.url
|
|
||||||
}
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: title.value,
|
|
||||||
content: content.value,
|
|
||||||
categoryId: selectedCategory.value,
|
|
||||||
tagIds: selectedTags.value,
|
|
||||||
type: postType.value,
|
|
||||||
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
|
|
||||||
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
|
|
||||||
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
|
|
||||||
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
|
||||||
startTime:
|
|
||||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
|
||||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
|
||||||
endTime:
|
|
||||||
postType.value === 'LOTTERY'
|
|
||||||
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
selectedTags.value[i] = data.id
|
||||||
if (data.reward && data.reward > 0) {
|
// update local TagSelect options handled by component
|
||||||
toast.success(`发布成功,获得 ${data.reward} 经验值`)
|
} else {
|
||||||
} else {
|
let data
|
||||||
toast.success('发布成功')
|
try {
|
||||||
}
|
data = await res.json()
|
||||||
if (data.id) {
|
} catch (e) {
|
||||||
window.location.href = `/posts/${data.id}`
|
data = null
|
||||||
}
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
toast.error('发布过于频繁,请稍后再试')
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '发布失败')
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
toast.error((data && data.error) || '创建标签失败')
|
||||||
toast.error('发布失败')
|
throw new Error('create tag failed')
|
||||||
} finally {
|
|
||||||
isWaitingPosting.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
}
|
||||||
title,
|
}
|
||||||
content,
|
|
||||||
selectedCategory,
|
const aiGenerate = async () => {
|
||||||
selectedTags,
|
if (!content.value.trim()) {
|
||||||
postType,
|
toast.error('内容为空,无法优化')
|
||||||
prizeIcon,
|
return
|
||||||
prizeCount,
|
}
|
||||||
endTime,
|
isAiLoading.value = true
|
||||||
submitPost,
|
try {
|
||||||
saveDraft,
|
toast.info('AI 优化中...')
|
||||||
clearPost,
|
const token = getToken()
|
||||||
isWaitingPosting,
|
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||||
aiGenerate,
|
method: 'POST',
|
||||||
isAiLoading,
|
headers: {
|
||||||
isLogin,
|
'Content-Type': 'application/json',
|
||||||
onPrizeIconChange,
|
Authorization: `Bearer ${token}`,
|
||||||
onPrizeCropped,
|
},
|
||||||
showPrizeCropper,
|
body: JSON.stringify({ text: content.value }),
|
||||||
tempPrizeIcon,
|
})
|
||||||
dateConfig,
|
if (res.ok) {
|
||||||
prizeName,
|
const data = await res.json()
|
||||||
prizeDescription,
|
content.value = data.content || ''
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('今日AI优化次数已用尽')
|
||||||
|
} else {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
|
} finally {
|
||||||
|
isAiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPost = async () => {
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
toast.error('标题不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
toast.error('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length === 0) {
|
||||||
|
toast.error('请选择标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (postType.value === 'LOTTERY') {
|
||||||
|
if (!prizeIcon.value) {
|
||||||
|
toast.error('请上传奖品图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prizeCount.value || prizeCount.value < 1) {
|
||||||
|
toast.error('奖品数量必须大于0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prizeDescription.value) {
|
||||||
|
toast.error('请输入奖品描述')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!endTime.value) {
|
||||||
|
toast.error('请选择抽奖结束时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
await ensureTags(token)
|
||||||
|
isWaitingPosting.value = true
|
||||||
|
let prizeIconUrl = prizeIcon.value
|
||||||
|
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', prizeIconFile.value)
|
||||||
|
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
const uploadData = await uploadRes.json()
|
||||||
|
if (!uploadRes.ok || uploadData.code !== 0) {
|
||||||
|
toast.error('奖品图片上传失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prizeIconUrl = uploadData.data.url
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value,
|
||||||
|
tagIds: selectedTags.value,
|
||||||
|
type: postType.value,
|
||||||
|
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
|
||||||
|
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
|
||||||
|
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
|
||||||
|
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
||||||
|
startTime:
|
||||||
|
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||||
|
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
||||||
|
endTime:
|
||||||
|
postType.value === 'LOTTERY'
|
||||||
|
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
if (data.reward && data.reward > 0) {
|
||||||
|
toast.success(`发布成功,获得 ${data.reward} 经验值`)
|
||||||
|
} else {
|
||||||
|
toast.success('发布成功')
|
||||||
|
}
|
||||||
|
if (data.id) {
|
||||||
|
window.location.href = `/posts/${data.id}`
|
||||||
|
}
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('发布过于频繁,请稍后再试')
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '发布失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('发布失败')
|
||||||
|
} finally {
|
||||||
|
isWaitingPosting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -35,186 +35,168 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import PostEditor from '~/components/PostEditor.vue'
|
import PostEditor from '~/components/PostEditor.vue'
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { getToken, authState } from '~/utils/auth'
|
import { getToken, authState } from '~/utils/auth'
|
||||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const title = ref('')
|
||||||
name: 'EditPostPageView',
|
const content = ref('')
|
||||||
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
|
const selectedCategory = ref('')
|
||||||
setup() {
|
const selectedTags = ref([])
|
||||||
const title = ref('')
|
const isWaitingPosting = ref(false)
|
||||||
const content = ref('')
|
const isAiLoading = ref(false)
|
||||||
const selectedCategory = ref('')
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
const selectedTags = ref([])
|
|
||||||
const isWaitingPosting = ref(false)
|
|
||||||
const isAiLoading = ref(false)
|
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const postId = route.params.id
|
||||||
const postId = route.params.id
|
|
||||||
|
|
||||||
const loadPost = async () => {
|
const loadPost = async () => {
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
title.value = data.title || ''
|
title.value = data.title || ''
|
||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.category.id || ''
|
selectedCategory.value = data.category.id || ''
|
||||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('加载失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadPost)
|
onMounted(loadPost)
|
||||||
|
|
||||||
const clearPost = () => {
|
const clearPost = () => {
|
||||||
title.value = ''
|
title.value = ''
|
||||||
content.value = ''
|
content.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureTags = async (token) => {
|
const ensureTags = async (token) => {
|
||||||
for (let i = 0; i < selectedTags.value.length; i++) {
|
for (let i = 0; i < selectedTags.value.length; i++) {
|
||||||
const t = selectedTags.value[i]
|
const t = selectedTags.value[i]
|
||||||
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
||||||
const name = t.slice(8)
|
const name = t.slice(8)
|
||||||
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name, description: '' }),
|
body: JSON.stringify({ name, description: '' }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
|
||||||
selectedTags.value[i] = data.id
|
|
||||||
// update local TagSelect options handled by component
|
|
||||||
} else {
|
|
||||||
let data
|
|
||||||
try {
|
|
||||||
data = await res.json()
|
|
||||||
} catch (e) {
|
|
||||||
data = null
|
|
||||||
}
|
|
||||||
toast.error((data && data.error) || '创建标签失败')
|
|
||||||
throw new Error('create tag failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiGenerate = async () => {
|
|
||||||
if (!content.value.trim()) {
|
|
||||||
toast.error('内容为空,无法优化')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isAiLoading.value = true
|
|
||||||
try {
|
|
||||||
toast.info('AI 优化中...')
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ text: content.value }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
content.value = data.content || ''
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
toast.error('今日AI优化次数已用尽')
|
|
||||||
} else {
|
|
||||||
toast.error('AI 优化失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('AI 优化失败')
|
|
||||||
} finally {
|
|
||||||
isAiLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitPost = async () => {
|
|
||||||
if (!title.value.trim()) {
|
|
||||||
toast.error('标题不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!content.value.trim()) {
|
|
||||||
toast.error('内容不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!selectedCategory.value) {
|
|
||||||
toast.error('请选择分类')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length === 0) {
|
|
||||||
toast.error('请选择标签')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
await ensureTags(token)
|
|
||||||
isWaitingPosting.value = true
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: title.value,
|
|
||||||
content: content.value,
|
|
||||||
categoryId: selectedCategory.value,
|
|
||||||
tagIds: selectedTags.value,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
selectedTags.value[i] = data.id
|
||||||
toast.success('更新成功')
|
// update local TagSelect options handled by component
|
||||||
window.location.href = `/posts/${postId}`
|
} else {
|
||||||
} else {
|
let data
|
||||||
toast.error(data.error || '更新失败')
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
data = null
|
||||||
}
|
}
|
||||||
} catch (e) {
|
toast.error((data && data.error) || '创建标签失败')
|
||||||
toast.error('更新失败')
|
throw new Error('create tag failed')
|
||||||
} finally {
|
|
||||||
isWaitingPosting.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cancelEdit = () => {
|
}
|
||||||
router.push(`/posts/${postId}`)
|
}
|
||||||
|
|
||||||
|
const aiGenerate = async () => {
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容为空,无法优化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isAiLoading.value = true
|
||||||
|
try {
|
||||||
|
toast.info('AI 优化中...')
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: content.value }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
content.value = data.content || ''
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('今日AI优化次数已用尽')
|
||||||
|
} else {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
}
|
}
|
||||||
return {
|
} catch (e) {
|
||||||
title,
|
toast.error('AI 优化失败')
|
||||||
content,
|
} finally {
|
||||||
selectedCategory,
|
isAiLoading.value = false
|
||||||
selectedTags,
|
}
|
||||||
submitPost,
|
}
|
||||||
clearPost,
|
|
||||||
cancelEdit,
|
const submitPost = async () => {
|
||||||
isWaitingPosting,
|
if (!title.value.trim()) {
|
||||||
aiGenerate,
|
toast.error('标题不能为空')
|
||||||
isAiLoading,
|
return
|
||||||
isLogin,
|
}
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
toast.error('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length === 0) {
|
||||||
|
toast.error('请选择标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
await ensureTags(token)
|
||||||
|
isWaitingPosting.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value,
|
||||||
|
tagIds: selectedTags.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('更新成功')
|
||||||
|
window.location.href = `/posts/${postId}`
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '更新失败')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
|
toast.error('更新失败')
|
||||||
|
} finally {
|
||||||
|
isWaitingPosting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cancelEdit = () => {
|
||||||
|
navigateTo(`/posts/${postId}`, { replace: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+215
-157
@@ -36,6 +36,13 @@
|
|||||||
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||||
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row switch-row">
|
||||||
|
<div class="setting-title">毛玻璃效果</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="frosted" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||||
<h3>管理员设置</h3>
|
<h3>管理员设置</h3>
|
||||||
@@ -64,173 +71,172 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||||
export default {
|
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||||
name: 'SettingsPageView',
|
const config = useRuntimeConfig()
|
||||||
components: { BaseInput, Dropdown, AvatarCropper },
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
data() {
|
const username = ref('')
|
||||||
return {
|
const introduction = ref('')
|
||||||
username: '',
|
const usernameError = ref('')
|
||||||
introduction: '',
|
const avatar = ref('')
|
||||||
usernameError: '',
|
const avatarFile = ref(null)
|
||||||
avatar: '',
|
const tempAvatar = ref('')
|
||||||
avatarFile: null,
|
const showCropper = ref(false)
|
||||||
tempAvatar: '',
|
const role = ref('')
|
||||||
showCropper: false,
|
const publishMode = ref('DIRECT')
|
||||||
role: '',
|
const passwordStrength = ref('LOW')
|
||||||
publishMode: 'DIRECT',
|
const aiFormatLimit = ref(3)
|
||||||
passwordStrength: 'LOW',
|
const registerMode = ref('DIRECT')
|
||||||
aiFormatLimit: 3,
|
const isLoadingPage = ref(false)
|
||||||
registerMode: 'DIRECT',
|
const isSaving = ref(false)
|
||||||
isLoadingPage: false,
|
const frosted = ref(true)
|
||||||
isSaving: false,
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isLoadingPage.value = true
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
username.value = user.username
|
||||||
|
introduction.value = user.introduction || ''
|
||||||
|
avatar.value = user.avatar
|
||||||
|
role.value = user.role
|
||||||
|
if (role.value === 'ADMIN') {
|
||||||
|
loadAdminConfig()
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
async mounted() {
|
toast.error('请先登录')
|
||||||
this.isLoadingPage = true
|
navigateTo('/login', { replace: true })
|
||||||
const user = await fetchCurrentUser()
|
}
|
||||||
|
isLoadingPage.value = false
|
||||||
|
frosted.value = frostedState.enabled
|
||||||
|
})
|
||||||
|
|
||||||
if (user) {
|
const onAvatarChange = (e) => {
|
||||||
this.username = user.username
|
const file = e.target.files[0]
|
||||||
this.introduction = user.introduction || ''
|
if (file) {
|
||||||
this.avatar = user.avatar
|
const reader = new FileReader()
|
||||||
this.role = user.role
|
reader.onload = () => {
|
||||||
if (this.role === 'ADMIN') {
|
tempAvatar.value = reader.result
|
||||||
this.loadAdminConfig()
|
showCropper.value = true
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('请先登录')
|
|
||||||
this.$router.push('/login')
|
|
||||||
}
|
}
|
||||||
this.isLoadingPage = false
|
reader.readAsDataURL(file)
|
||||||
},
|
}
|
||||||
methods: {
|
}
|
||||||
onAvatarChange(e) {
|
watch(frosted, (val) => setFrosted(val))
|
||||||
const file = e.target.files[0]
|
const onCropped = ({ file, url }) => {
|
||||||
if (file) {
|
avatarFile.value = file
|
||||||
const reader = new FileReader()
|
avatar.value = url
|
||||||
reader.onload = () => {
|
}
|
||||||
this.tempAvatar = reader.result
|
const fetchPublishModes = () => {
|
||||||
this.showCropper = true
|
return Promise.resolve([
|
||||||
}
|
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||||
reader.readAsDataURL(file)
|
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const fetchPasswordStrengths = () => {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
||||||
|
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
||||||
|
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const fetchAiLimits = () => {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 3, name: '3次' },
|
||||||
|
{ id: 5, name: '5次' },
|
||||||
|
{ id: 10, name: '10次' },
|
||||||
|
{ id: -1, name: '无限' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const fetchRegisterModes = () => {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
||||||
|
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const loadAdminConfig = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
publishMode.value = data.publishMode
|
||||||
|
passwordStrength.value = data.passwordStrength
|
||||||
|
aiFormatLimit.value = data.aiFormatLimit
|
||||||
|
registerMode.value = data.registerMode
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const save = async () => {
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let token = getToken()
|
||||||
|
usernameError.value = ''
|
||||||
|
if (!username.value) {
|
||||||
|
usernameError.value = '用户名不能为空'
|
||||||
|
}
|
||||||
|
if (usernameError.value) {
|
||||||
|
toast.error(usernameError.value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (avatarFile.value) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', avatarFile.value)
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
avatar.value = data.url
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '上传失败')
|
||||||
|
break
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onCropped({ file, url }) {
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
this.avatarFile = file
|
method: 'PUT',
|
||||||
this.avatar = url
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
},
|
body: JSON.stringify({ username: username.value, introduction: introduction.value }),
|
||||||
fetchPublishModes() {
|
})
|
||||||
return Promise.resolve([
|
|
||||||
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
|
||||||
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
fetchPasswordStrengths() {
|
|
||||||
return Promise.resolve([
|
|
||||||
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
|
||||||
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
|
||||||
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
fetchAiLimits() {
|
|
||||||
return Promise.resolve([
|
|
||||||
{ id: 3, name: '3次' },
|
|
||||||
{ id: 5, name: '5次' },
|
|
||||||
{ id: 10, name: '10次' },
|
|
||||||
{ id: -1, name: '无限' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
fetchRegisterModes() {
|
|
||||||
return Promise.resolve([
|
|
||||||
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
|
||||||
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
async loadAdminConfig() {
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
this.publishMode = data.publishMode
|
|
||||||
this.passwordStrength = data.passwordStrength
|
|
||||||
this.aiFormatLimit = data.aiFormatLimit
|
|
||||||
this.registerMode = data.registerMode
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async save() {
|
|
||||||
this.isSaving = true
|
|
||||||
|
|
||||||
do {
|
const data = await res.json()
|
||||||
let token = getToken()
|
if (!res.ok) {
|
||||||
this.usernameError = ''
|
toast.error(data.error || '保存失败')
|
||||||
if (!this.username) {
|
break
|
||||||
this.usernameError = '用户名不能为空'
|
}
|
||||||
}
|
if (data.token) {
|
||||||
if (this.usernameError) {
|
setToken(data.token)
|
||||||
toast.error(this.usernameError)
|
token = data.token
|
||||||
break
|
}
|
||||||
}
|
if (role.value === 'ADMIN') {
|
||||||
if (this.avatarFile) {
|
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
const form = new FormData()
|
method: 'POST',
|
||||||
form.append('file', this.avatarFile)
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
body: JSON.stringify({
|
||||||
method: 'POST',
|
publishMode: publishMode.value,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
passwordStrength: passwordStrength.value,
|
||||||
body: form,
|
aiFormatLimit: aiFormatLimit.value,
|
||||||
})
|
registerMode: registerMode.value,
|
||||||
const data = await res.json()
|
}),
|
||||||
if (res.ok) {
|
})
|
||||||
this.avatar = data.url
|
}
|
||||||
} else {
|
toast.success('保存成功')
|
||||||
toast.error(data.error || '上传失败')
|
} while (!isSaving.value)
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ username: this.username, introduction: this.introduction }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await res.json()
|
isSaving.value = false
|
||||||
if (!res.ok) {
|
|
||||||
toast.error(data.error || '保存失败')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (data.token) {
|
|
||||||
setToken(data.token)
|
|
||||||
token = data.token
|
|
||||||
}
|
|
||||||
if (this.role === 'ADMIN') {
|
|
||||||
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({
|
|
||||||
publishMode: this.publishMode,
|
|
||||||
passwordStrength: this.passwordStrength,
|
|
||||||
aiFormatLimit: this.aiFormatLimit,
|
|
||||||
registerMode: this.registerMode,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
toast.success('保存成功')
|
|
||||||
} while (!this.isSaving)
|
|
||||||
|
|
||||||
this.isSaving = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -305,6 +311,58 @@ export default {
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,63 +18,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const reason = ref('')
|
||||||
name: 'SignupReasonPageView',
|
const error = ref('')
|
||||||
components: { BaseInput },
|
const isWaitingForRegister = ref(false)
|
||||||
data() {
|
const token = ref('')
|
||||||
return {
|
|
||||||
reason: '',
|
|
||||||
error: '',
|
|
||||||
isWaitingForRegister: false,
|
|
||||||
token: '',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.token = this.$route.query.token || ''
|
|
||||||
if (!this.token) {
|
|
||||||
this.$router.push('/signup')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async submit() {
|
|
||||||
if (!this.reason || this.reason.trim().length < 20) {
|
|
||||||
this.error = '请至少输入20个字'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
onMounted(async () => {
|
||||||
this.isWaitingForRegister = true
|
token.value = route.query.token || ''
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
|
if (!token.value) {
|
||||||
method: 'POST',
|
await navigateTo({ path: '/signup' }, { replace: true })
|
||||||
headers: {
|
}
|
||||||
'Content-Type': 'application/json',
|
})
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
const submit = async () => {
|
||||||
token: this.token,
|
if (!reason.value || reason.value.trim().length < 20) {
|
||||||
reason: this.reason,
|
error.value = '请至少输入20个字'
|
||||||
}),
|
return
|
||||||
})
|
}
|
||||||
this.isWaitingForRegister = false
|
|
||||||
const data = await res.json()
|
try {
|
||||||
if (res.ok) {
|
isWaitingForRegister.value = true
|
||||||
toast.success('注册理由已提交,请等待审核')
|
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
|
||||||
this.$router.push('/')
|
method: 'POST',
|
||||||
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
|
headers: {
|
||||||
toast.error('登录已过期,请重新登录')
|
'Content-Type': 'application/json',
|
||||||
this.$router.push('/login')
|
},
|
||||||
} else {
|
body: JSON.stringify({
|
||||||
toast.error(data.error || '提交失败')
|
token: this.token,
|
||||||
}
|
reason: this.reason,
|
||||||
} catch (e) {
|
}),
|
||||||
this.isWaitingForRegister = false
|
})
|
||||||
toast.error('提交失败')
|
isWaitingForRegister.value = false
|
||||||
}
|
const data = await res.json()
|
||||||
},
|
if (res.ok) {
|
||||||
},
|
toast.success('注册理由已提交,请等待审核')
|
||||||
|
await navigateTo('/', { replace: true })
|
||||||
|
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
|
||||||
|
toast.error('登录已过期,请重新登录')
|
||||||
|
await navigateTo('/login', { replace: true })
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '提交失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
isWaitingForRegister.value = false
|
||||||
|
toast.error('提交失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+114
-121
@@ -89,135 +89,128 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { discordAuthorize } from '~/utils/discord'
|
import { discordAuthorize } from '~/utils/discord'
|
||||||
import { githubAuthorize } from '~/utils/github'
|
import { githubAuthorize } from '~/utils/github'
|
||||||
import { googleAuthorize } from '~/utils/google'
|
import { googleAuthorize } from '~/utils/google'
|
||||||
import { twitterAuthorize } from '~/utils/twitter'
|
import { twitterAuthorize } from '~/utils/twitter'
|
||||||
export default {
|
const config = useRuntimeConfig()
|
||||||
name: 'SignupPageView',
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
components: { BaseInput },
|
const emailStep = ref(0)
|
||||||
setup() {
|
const email = ref('')
|
||||||
return { googleAuthorize }
|
const username = ref('')
|
||||||
},
|
const password = ref('')
|
||||||
data() {
|
const registerMode = ref('DIRECT')
|
||||||
return {
|
const emailError = ref('')
|
||||||
emailStep: 0,
|
const usernameError = ref('')
|
||||||
email: '',
|
const passwordError = ref('')
|
||||||
username: '',
|
const code = ref('')
|
||||||
password: '',
|
const isWaitingForEmailSent = ref(false)
|
||||||
registerMode: 'DIRECT',
|
const isWaitingForEmailVerified = ref(false)
|
||||||
emailError: '',
|
|
||||||
usernameError: '',
|
onMounted(async () => {
|
||||||
passwordError: '',
|
username.value = route.query.u || ''
|
||||||
code: '',
|
try {
|
||||||
isWaitingForEmailSent: false,
|
const res = await fetch(`${API_BASE_URL}/api/config`)
|
||||||
isWaitingForEmailVerified: false,
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
registerMode.value = data.registerMode
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
async mounted() {
|
/* ignore */
|
||||||
this.username = this.$route.query.u || ''
|
}
|
||||||
try {
|
if (route.query.verify) {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/config`)
|
emailStep.value = 1
|
||||||
if (res.ok) {
|
}
|
||||||
const data = await res.json()
|
})
|
||||||
this.registerMode = data.registerMode
|
|
||||||
}
|
const clearErrors = () => {
|
||||||
} catch {
|
emailError.value = ''
|
||||||
/* ignore */
|
usernameError.value = ''
|
||||||
|
passwordError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendVerification = async () => {
|
||||||
|
clearErrors()
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(email.value)) {
|
||||||
|
emailError.value = '邮箱格式不正确'
|
||||||
|
}
|
||||||
|
if (!password.value || password.value.length < 6) {
|
||||||
|
passwordError.value = '密码至少6位'
|
||||||
|
}
|
||||||
|
if (!username.value) {
|
||||||
|
usernameError.value = '用户名不能为空'
|
||||||
|
}
|
||||||
|
if (emailError.value || passwordError.value || usernameError.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
isWaitingForEmailSent.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username.value,
|
||||||
|
email: email.value,
|
||||||
|
password: password.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
isWaitingForEmailSent.value = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
emailStep.value = 1
|
||||||
|
toast.success('验证码已发送,请查看邮箱')
|
||||||
|
} else if (data.field) {
|
||||||
|
if (data.field === 'username') usernameError.value = data.error
|
||||||
|
if (data.field === 'email') emailError.value = data.error
|
||||||
|
if (data.field === 'password') passwordError.value = data.error
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '发送失败')
|
||||||
}
|
}
|
||||||
if (this.$route.query.verify) {
|
} catch (e) {
|
||||||
this.emailStep = 1
|
toast.error('发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyCode = async () => {
|
||||||
|
try {
|
||||||
|
isWaitingForEmailVerified.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: code.value,
|
||||||
|
username: username.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
if (registerMode.value === 'WHITELIST') {
|
||||||
|
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
|
||||||
|
} else {
|
||||||
|
toast.success('注册成功,请登录')
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '注册失败')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
methods: {
|
toast.error('注册失败')
|
||||||
clearErrors() {
|
} finally {
|
||||||
this.emailError = ''
|
isWaitingForEmailVerified.value = false
|
||||||
this.usernameError = ''
|
}
|
||||||
this.passwordError = ''
|
}
|
||||||
},
|
const signupWithGithub = () => {
|
||||||
async sendVerification() {
|
githubAuthorize()
|
||||||
this.clearErrors()
|
}
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const signupWithDiscord = () => {
|
||||||
if (!emailRegex.test(this.email)) {
|
discordAuthorize()
|
||||||
this.emailError = '邮箱格式不正确'
|
}
|
||||||
}
|
const signupWithTwitter = () => {
|
||||||
if (!this.password || this.password.length < 6) {
|
twitterAuthorize()
|
||||||
this.passwordError = '密码至少6位'
|
|
||||||
}
|
|
||||||
if (!this.username) {
|
|
||||||
this.usernameError = '用户名不能为空'
|
|
||||||
}
|
|
||||||
if (this.emailError || this.passwordError || this.usernameError) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.isWaitingForEmailSent = true
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: this.username,
|
|
||||||
email: this.email,
|
|
||||||
password: this.password,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
this.isWaitingForEmailSent = false
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok) {
|
|
||||||
this.emailStep = 1
|
|
||||||
toast.success('验证码已发送,请查看邮箱')
|
|
||||||
} else if (data.field) {
|
|
||||||
if (data.field === 'username') this.usernameError = data.error
|
|
||||||
if (data.field === 'email') this.emailError = data.error
|
|
||||||
if (data.field === 'password') this.passwordError = data.error
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '发送失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('发送失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async verifyCode() {
|
|
||||||
try {
|
|
||||||
this.isWaitingForEmailVerified = true
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: this.code,
|
|
||||||
username: this.username,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok) {
|
|
||||||
if (this.registerMode === 'WHITELIST') {
|
|
||||||
this.$router.push('/signup-reason?token=' + data.token)
|
|
||||||
} else {
|
|
||||||
toast.success('注册成功,请登录')
|
|
||||||
this.$router.push('/login')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '注册失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('注册失败')
|
|
||||||
} finally {
|
|
||||||
this.isWaitingForEmailVerified = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
signupWithGithub() {
|
|
||||||
githubAuthorize()
|
|
||||||
},
|
|
||||||
signupWithDiscord() {
|
|
||||||
discordAuthorize()
|
|
||||||
},
|
|
||||||
signupWithTwitter() {
|
|
||||||
twitterAuthorize()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,20 @@
|
|||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import CallbackPage from '~/components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { twitterExchange } from '~/utils/twitter'
|
import { twitterExchange } from '~/utils/twitter'
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'TwitterCallbackPageView',
|
const url = new URL(window.location.href)
|
||||||
components: { CallbackPage },
|
const code = url.searchParams.get('code')
|
||||||
async mounted() {
|
const state = url.searchParams.get('state')
|
||||||
const url = new URL(window.location.href)
|
const result = await twitterExchange(code, state, '')
|
||||||
const code = url.searchParams.get('code')
|
|
||||||
const state = url.searchParams.get('state')
|
|
||||||
const result = await twitterExchange(code, state, '')
|
|
||||||
|
|
||||||
if (result.needReason) {
|
if (result.needReason) {
|
||||||
this.$router.push('/signup-reason?token=' + result.token)
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
this.$router.push('/')
|
navigateTo('/', { replace: true })
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+339
-331
@@ -35,10 +35,12 @@
|
|||||||
/>
|
/>
|
||||||
<div class="profile-level-target">
|
<div class="profile-level-target">
|
||||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||||
<i
|
<ToolTip
|
||||||
class="fas fa-info-circle profile-exp-info"
|
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||||
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
placement="bottom"
|
||||||
></i>
|
>
|
||||||
|
<i class="fas fa-info-circle profile-exp-info"></i>
|
||||||
|
</ToolTip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,67 +206,89 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||||
|
<div class="timeline-tabs">
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||||
|
@click="timelineFilter = 'all'"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||||
|
@click="timelineFilter = 'articles'"
|
||||||
|
>
|
||||||
|
文章
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||||
|
@click="timelineFilter = 'comments'"
|
||||||
|
>
|
||||||
|
评论和回复
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<BasePlaceholder
|
<BasePlaceholder
|
||||||
v-if="timelineItems.length === 0"
|
v-if="filteredTimelineItems.length === 0"
|
||||||
text="暂无时间线"
|
text="暂无时间线"
|
||||||
icon="fas fa-inbox"
|
icon="fas fa-inbox"
|
||||||
/>
|
/>
|
||||||
<BaseTimeline :items="timelineItems">
|
<div class="timeline-list">
|
||||||
<template #item="{ item }">
|
<BaseTimeline :items="filteredTimelineItems">
|
||||||
<template v-if="item.type === 'post'">
|
<template #item="{ item }">
|
||||||
发布了文章
|
<template v-if="item.type === 'post'">
|
||||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
发布了文章
|
||||||
{{ item.post.title }}
|
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||||
</router-link>
|
{{ item.post.title }}
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
</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>
|
||||||
<template v-else-if="item.type === 'comment'">
|
</BaseTimeline>
|
||||||
在
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||||
@@ -296,7 +320,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import AchievementList from '~/components/AchievementList.vue'
|
import AchievementList from '~/components/AchievementList.vue'
|
||||||
@@ -304,278 +328,251 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
|||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import LevelProgress from '~/components/LevelProgress.vue'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import UserList from '~/components/UserList.vue'
|
import UserList from '~/components/UserList.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { prevLevelExp } from '~/utils/level'
|
import { prevLevelExp } from '~/utils/level'
|
||||||
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
definePageMeta({
|
||||||
name: 'ProfileView',
|
alias: ['/users/:id/'],
|
||||||
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
|
})
|
||||||
setup() {
|
const route = useRoute()
|
||||||
definePageMeta({
|
const router = useRouter()
|
||||||
alias: ['/users/:id/'],
|
const username = route.params.id
|
||||||
})
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const username = route.params.id
|
|
||||||
|
|
||||||
const user = ref({})
|
const user = ref({})
|
||||||
const hotPosts = ref([])
|
const hotPosts = ref([])
|
||||||
const hotReplies = ref([])
|
const hotReplies = ref([])
|
||||||
const hotTags = ref([])
|
const hotTags = ref([])
|
||||||
const timelineItems = ref([])
|
const timelineItems = ref([])
|
||||||
const followers = ref([])
|
const timelineFilter = ref('all')
|
||||||
const followings = ref([])
|
const filteredTimelineItems = computed(() => {
|
||||||
const medals = ref([])
|
if (timelineFilter.value === 'articles') {
|
||||||
const subscribed = ref(false)
|
return timelineItems.value.filter((item) => item.type === 'post')
|
||||||
const isLoading = ref(true)
|
} else if (timelineFilter.value === 'comments') {
|
||||||
const tabLoading = ref(false)
|
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||||
const selectedTab = ref(
|
}
|
||||||
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
|
return timelineItems.value
|
||||||
? route.query.tab
|
})
|
||||||
: 'summary',
|
const followers = ref([])
|
||||||
)
|
const followings = ref([])
|
||||||
const followTab = ref('followers')
|
const medals = ref([])
|
||||||
|
const subscribed = ref(false)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const tabLoading = ref(false)
|
||||||
|
const selectedTab = ref(
|
||||||
|
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
|
||||||
|
? route.query.tab
|
||||||
|
: 'summary',
|
||||||
|
)
|
||||||
|
const followTab = ref('followers')
|
||||||
|
|
||||||
const levelInfo = computed(() => {
|
const levelInfo = computed(() => {
|
||||||
const exp = user.value.experience || 0
|
const exp = user.value.experience || 0
|
||||||
const currentLevel = user.value.currentLevel || 0
|
const currentLevel = user.value.currentLevel || 0
|
||||||
const nextExp = user.value.nextLevelExp || 0
|
const nextExp = user.value.nextLevelExp || 0
|
||||||
const prevExp = prevLevelExp(currentLevel)
|
const prevExp = prevLevelExp(currentLevel)
|
||||||
const total = nextExp - prevExp
|
const total = nextExp - prevExp
|
||||||
const ratio = total > 0 ? (exp - prevExp) / total : 1
|
const ratio = total > 0 ? (exp - prevExp) / total : 1
|
||||||
const percent = Math.max(0, Math.min(1, ratio)) * 100
|
const percent = Math.max(0, Math.min(1, ratio)) * 100
|
||||||
return { exp, currentLevel, nextExp, percent }
|
return { exp, currentLevel, nextExp, percent }
|
||||||
})
|
})
|
||||||
|
|
||||||
const isMine = computed(function () {
|
const isMine = computed(function () {
|
||||||
const mine = authState.username === username || String(authState.userId) === username
|
const mine = authState.username === username || String(authState.userId) === username
|
||||||
console.log(mine)
|
console.log(mine)
|
||||||
return mine
|
return mine
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDate = (d) => {
|
const formatDate = (d) => {
|
||||||
if (!d) return ''
|
if (!d) return ''
|
||||||
return TimeManager.format(d)
|
return TimeManager.format(d)
|
||||||
}
|
|
||||||
|
|
||||||
const fetchUser = async () => {
|
|
||||||
const token = getToken()
|
|
||||||
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers })
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
user.value = data
|
|
||||||
subscribed.value = !!data.subscribed
|
|
||||||
} else if (res.status === 404) {
|
|
||||||
router.replace('/404')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchSummary = async () => {
|
|
||||||
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
|
|
||||||
if (postsRes.ok) {
|
|
||||||
const data = await postsRes.json()
|
|
||||||
hotPosts.value = data.map((p) => ({ icon: 'fas fa-book', post: p }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
|
|
||||||
if (repliesRes.ok) {
|
|
||||||
const data = await repliesRes.json()
|
|
||||||
hotReplies.value = data.map((c) => ({ icon: 'fas fa-comment', comment: c }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
|
|
||||||
if (tagsRes.ok) {
|
|
||||||
const data = await tagsRes.json()
|
|
||||||
hotTags.value = data.map((t) => ({ icon: 'fas fa-tag', tag: t }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchTimeline = async () => {
|
|
||||||
const [postsRes, repliesRes, tagsRes] = await Promise.all([
|
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
|
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`),
|
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`),
|
|
||||||
])
|
|
||||||
const posts = postsRes.ok ? await postsRes.json() : []
|
|
||||||
const replies = repliesRes.ok ? await repliesRes.json() : []
|
|
||||||
const tags = tagsRes.ok ? await tagsRes.json() : []
|
|
||||||
const mapped = [
|
|
||||||
...posts.map((p) => ({
|
|
||||||
type: 'post',
|
|
||||||
icon: 'fas fa-book',
|
|
||||||
post: p,
|
|
||||||
createdAt: p.createdAt,
|
|
||||||
})),
|
|
||||||
...replies.map((r) => ({
|
|
||||||
type: r.parentComment ? 'reply' : 'comment',
|
|
||||||
icon: 'fas fa-comment',
|
|
||||||
comment: r,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
})),
|
|
||||||
...tags.map((t) => ({
|
|
||||||
type: 'tag',
|
|
||||||
icon: 'fas fa-tag',
|
|
||||||
tag: t,
|
|
||||||
createdAt: t.createdAt,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
||||||
timelineItems.value = mapped
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchFollowUsers = async () => {
|
|
||||||
const [followerRes, followingRes] = await Promise.all([
|
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/following`),
|
|
||||||
])
|
|
||||||
followers.value = followerRes.ok ? await followerRes.json() : []
|
|
||||||
followings.value = followingRes.ok ? await followingRes.json() : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadSummary = async () => {
|
|
||||||
tabLoading.value = true
|
|
||||||
await fetchSummary()
|
|
||||||
tabLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadTimeline = async () => {
|
|
||||||
tabLoading.value = true
|
|
||||||
await fetchTimeline()
|
|
||||||
tabLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadFollow = async () => {
|
|
||||||
tabLoading.value = true
|
|
||||||
await fetchFollowUsers()
|
|
||||||
tabLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchAchievements = async () => {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
|
|
||||||
if (res.ok) {
|
|
||||||
medals.value = await res.json()
|
|
||||||
} else {
|
|
||||||
medals.value = []
|
|
||||||
toast.error('获取成就失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadAchievements = async () => {
|
|
||||||
tabLoading.value = true
|
|
||||||
await fetchAchievements()
|
|
||||||
tabLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscribeUser = async () => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
subscribed.value = true
|
|
||||||
toast.success('已关注')
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribeUser = async () => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
subscribed.value = false
|
|
||||||
toast.success('已取消关注')
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotoTag = (tag) => {
|
|
||||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
|
||||||
router.push({ path: '/', query: { tags: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const init = async () => {
|
|
||||||
try {
|
|
||||||
await fetchUser()
|
|
||||||
if (selectedTab.value === 'summary') {
|
|
||||||
await loadSummary()
|
|
||||||
} else if (selectedTab.value === 'timeline') {
|
|
||||||
await loadTimeline()
|
|
||||||
} else if (selectedTab.value === 'following') {
|
|
||||||
await loadFollow()
|
|
||||||
} else if (selectedTab.value === 'achievements') {
|
|
||||||
await loadAchievements()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(init)
|
|
||||||
|
|
||||||
watch(selectedTab, async (val) => {
|
|
||||||
// router.replace({ query: { ...route.query, tab: val } })
|
|
||||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
|
||||||
await loadTimeline()
|
|
||||||
} else if (
|
|
||||||
val === 'following' &&
|
|
||||||
followers.value.length === 0 &&
|
|
||||||
followings.value.length === 0
|
|
||||||
) {
|
|
||||||
await loadFollow()
|
|
||||||
} else if (val === 'achievements' && medals.value.length === 0) {
|
|
||||||
await loadAchievements()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
hotPosts,
|
|
||||||
hotReplies,
|
|
||||||
timelineItems,
|
|
||||||
followers,
|
|
||||||
followings,
|
|
||||||
medals,
|
|
||||||
subscribed,
|
|
||||||
isMine,
|
|
||||||
isLoading,
|
|
||||||
tabLoading,
|
|
||||||
selectedTab,
|
|
||||||
followTab,
|
|
||||||
formatDate,
|
|
||||||
stripMarkdown,
|
|
||||||
stripMarkdownLength,
|
|
||||||
loadTimeline,
|
|
||||||
loadFollow,
|
|
||||||
loadAchievements,
|
|
||||||
loadSummary,
|
|
||||||
subscribeUser,
|
|
||||||
unsubscribeUser,
|
|
||||||
gotoTag,
|
|
||||||
hotTags,
|
|
||||||
levelInfo,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
user.value = data
|
||||||
|
subscribed.value = !!data.subscribed
|
||||||
|
} else if (res.status === 404) {
|
||||||
|
router.replace('/404')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSummary = async () => {
|
||||||
|
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
|
||||||
|
if (postsRes.ok) {
|
||||||
|
const data = await postsRes.json()
|
||||||
|
hotPosts.value = data.map((p) => ({ icon: 'fas fa-book', post: p }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
|
||||||
|
if (repliesRes.ok) {
|
||||||
|
const data = await repliesRes.json()
|
||||||
|
hotReplies.value = data.map((c) => ({ icon: 'fas fa-comment', comment: c }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
|
||||||
|
if (tagsRes.ok) {
|
||||||
|
const data = await tagsRes.json()
|
||||||
|
hotTags.value = data.map((t) => ({ icon: 'fas fa-tag', tag: t }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTimeline = async () => {
|
||||||
|
const [postsRes, repliesRes, tagsRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`),
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`),
|
||||||
|
])
|
||||||
|
const posts = postsRes.ok ? await postsRes.json() : []
|
||||||
|
const replies = repliesRes.ok ? await repliesRes.json() : []
|
||||||
|
const tags = tagsRes.ok ? await tagsRes.json() : []
|
||||||
|
const mapped = [
|
||||||
|
...posts.map((p) => ({
|
||||||
|
type: 'post',
|
||||||
|
icon: 'fas fa-book',
|
||||||
|
post: p,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
})),
|
||||||
|
...replies.map((r) => ({
|
||||||
|
type: r.parentComment ? 'reply' : 'comment',
|
||||||
|
icon: 'fas fa-comment',
|
||||||
|
comment: r,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
...tags.map((t) => ({
|
||||||
|
type: 'tag',
|
||||||
|
icon: 'fas fa-tag',
|
||||||
|
tag: t,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
timelineItems.value = mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFollowUsers = async () => {
|
||||||
|
const [followerRes, followingRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/following`),
|
||||||
|
])
|
||||||
|
followers.value = followerRes.ok ? await followerRes.json() : []
|
||||||
|
followings.value = followingRes.ok ? await followingRes.json() : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchSummary()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTimeline = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchTimeline()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFollow = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchFollowUsers()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAchievements = async () => {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
|
||||||
|
if (res.ok) {
|
||||||
|
medals.value = await res.json()
|
||||||
|
} else {
|
||||||
|
medals.value = []
|
||||||
|
toast.error('获取成就失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAchievements = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchAchievements()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeUser = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
subscribed.value = true
|
||||||
|
toast.success('已关注')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeUser = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
subscribed.value = false
|
||||||
|
toast.success('已取消关注')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gotoTag = (tag) => {
|
||||||
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
|
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
await fetchUser()
|
||||||
|
if (selectedTab.value === 'summary') {
|
||||||
|
await loadSummary()
|
||||||
|
} else if (selectedTab.value === 'timeline') {
|
||||||
|
await loadTimeline()
|
||||||
|
} else if (selectedTab.value === 'following') {
|
||||||
|
await loadFollow()
|
||||||
|
} else if (selectedTab.value === 'achievements') {
|
||||||
|
await loadAchievements()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
|
||||||
|
watch(selectedTab, async (val) => {
|
||||||
|
// router.replace({ query: { ...route.query, tab: val } })
|
||||||
|
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||||
|
await loadTimeline()
|
||||||
|
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||||
|
await loadFollow()
|
||||||
|
} else if (val === 'achievements' && medals.value.length === 0) {
|
||||||
|
await loadAchievements()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -690,12 +687,6 @@ export default {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-exp-info {
|
|
||||||
margin-left: 4px;
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info {
|
.profile-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -736,6 +727,7 @@ export default {
|
|||||||
border-bottom: 1px solid var(--normal-border-color);
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs-item {
|
.profile-tabs-item {
|
||||||
@@ -813,8 +805,24 @@ export default {
|
|||||||
width: 40%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-timeline {
|
.timeline-tabs {
|
||||||
padding: 20px;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tab-item {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tab-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-date {
|
.timeline-date {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
import ClickOutside from '~/directives/clickOutside.js'
|
import ClickOutside from '~/directives/clickOutside.js'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
|
import { initFrosted } from '~/utils/frosted'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
initFrosted()
|
||||||
|
})
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
export default {
|
|
||||||
push(path) {
|
|
||||||
if (process.client) {
|
|
||||||
window.location.href = path
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
const TOKEN_KEY = 'token'
|
const TOKEN_KEY = 'token'
|
||||||
@@ -65,6 +64,8 @@ export function clearUserInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCurrentUser() {
|
export async function fetchCurrentUser() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
try {
|
try {
|
||||||
@@ -91,6 +92,8 @@ export function isLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkToken() {
|
export async function checkToken() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return false
|
if (!token) return false
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { WEBSITE_BASE_URL } from '../constants'
|
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function discordAuthorize(state = '') {
|
export function discordAuthorize(state = '') {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
const DISCORD_CLIENT_ID = config.public.discordClientId
|
||||||
if (!DISCORD_CLIENT_ID) {
|
if (!DISCORD_CLIENT_ID) {
|
||||||
toast.error('Discord 登录不可用')
|
toast.error('Discord 登录不可用')
|
||||||
return
|
return
|
||||||
@@ -15,6 +17,8 @@ export function discordAuthorize(state = '') {
|
|||||||
|
|
||||||
export async function discordExchange(code, state, reason) {
|
export async function discordExchange(code, state, reason) {
|
||||||
try {
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { WEBSITE_BASE_URL } from '../constants'
|
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function githubAuthorize(state = '') {
|
export function githubAuthorize(state = '') {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
const GITHUB_CLIENT_ID = config.public.githubClientId
|
||||||
if (!GITHUB_CLIENT_ID) {
|
if (!GITHUB_CLIENT_ID) {
|
||||||
toast.error('GitHub 登录不可用')
|
toast.error('GitHub 登录不可用')
|
||||||
return
|
return
|
||||||
@@ -15,6 +17,8 @@ export function githubAuthorize(state = '') {
|
|||||||
|
|
||||||
export async function githubExchange(code, state, reason) {
|
export async function githubExchange(code, state, reason) {
|
||||||
try {
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
import { WEBSITE_BASE_URL } from '../constants'
|
|
||||||
|
|
||||||
export async function googleGetIdToken() {
|
export async function googleGetIdToken() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!window.google || !GOOGLE_CLIENT_ID) {
|
if (!window.google || !GOOGLE_CLIENT_ID) {
|
||||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||||
@@ -20,6 +22,9 @@ export async function googleGetIdToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function googleAuthorize() {
|
export function googleAuthorize() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
if (!GOOGLE_CLIENT_ID) {
|
if (!GOOGLE_CLIENT_ID) {
|
||||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||||
return
|
return
|
||||||
@@ -32,6 +37,8 @@ export function googleAuthorize() {
|
|||||||
|
|
||||||
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
||||||
try {
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -65,15 +72,13 @@ export async function googleSignIn(redirect_success, redirect_not_approved) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import router from '../router'
|
|
||||||
|
|
||||||
export function loginWithGoogle() {
|
export function loginWithGoogle() {
|
||||||
googleSignIn(
|
googleSignIn(
|
||||||
() => {
|
() => {
|
||||||
router.push('/')
|
navigateTo('/', { replace: true })
|
||||||
},
|
},
|
||||||
(token) => {
|
(token) => {
|
||||||
router.push('/signup-reason?token=' + token)
|
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import hljs from 'highlight.js'
|
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 MarkdownIt from 'markdown-it'
|
||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { tiebaEmoji } from './tiebaEmoji'
|
import { tiebaEmoji } from './tiebaEmoji'
|
||||||
@@ -50,6 +59,31 @@ function tiebaEmojiPlugin(md) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 链接在新窗口打开
|
||||||
|
function linkPlugin(md) {
|
||||||
|
const defaultRender =
|
||||||
|
md.renderer.rules.link_open ||
|
||||||
|
function (tokens, idx, options, env, self) {
|
||||||
|
return self.renderToken(tokens, idx, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||||
|
const token = tokens[idx]
|
||||||
|
const hrefIndex = token.attrIndex('href')
|
||||||
|
|
||||||
|
if (hrefIndex >= 0) {
|
||||||
|
const href = token.attrs[hrefIndex][1]
|
||||||
|
// 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer"
|
||||||
|
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||||
|
token.attrPush(['target', '_blank'])
|
||||||
|
token.attrPush(['rel', 'noopener noreferrer'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultRender(tokens, idx, options, env, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
@@ -61,12 +95,17 @@ const md = new MarkdownIt({
|
|||||||
} else {
|
} else {
|
||||||
code = hljs.highlightAuto(str).value
|
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>`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
md.use(mentionPlugin)
|
md.use(mentionPlugin)
|
||||||
md.use(tiebaEmojiPlugin)
|
md.use(tiebaEmojiPlugin)
|
||||||
|
md.use(linkPlugin) // 添加链接插件
|
||||||
|
|
||||||
export function renderMarkdown(text) {
|
export function renderMarkdown(text) {
|
||||||
return md.render(text || '')
|
return md.render(text || '')
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
import { API_BASE_URL } from '~/main'
|
import { navigateTo, useRuntimeConfig } from 'nuxt/app'
|
||||||
import { getToken } from './auth'
|
import { reactive, ref } from 'vue'
|
||||||
import { reactive } from 'vue'
|
import { toast } from '~/composables/useToast'
|
||||||
|
import { authState, getToken } from '~/utils/auth'
|
||||||
|
import { reactionEmojiMap } from '~/utils/reactions'
|
||||||
|
|
||||||
export const notificationState = reactive({
|
export const notificationState = reactive({
|
||||||
unreadCount: 0,
|
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',
|
||||||
|
LOTTERY_WIN: 'fas fa-trophy',
|
||||||
|
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||||
|
MENTION: 'fas fa-at',
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchUnreadCount() {
|
export async function fetchUnreadCount() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -31,6 +54,9 @@ export async function fetchUnreadCount() {
|
|||||||
|
|
||||||
export async function markNotificationsRead(ids) {
|
export async function markNotificationsRead(ids) {
|
||||||
try {
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token || !ids || ids.length === 0) return false
|
if (!token || !ids || ids.length === 0) return false
|
||||||
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
|
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
|
||||||
@@ -49,6 +75,9 @@ export async function markNotificationsRead(ids) {
|
|||||||
|
|
||||||
export async function fetchNotificationPreferences() {
|
export async function fetchNotificationPreferences() {
|
||||||
try {
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return []
|
if (!token) return []
|
||||||
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {
|
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {
|
||||||
@@ -63,6 +92,8 @@ export async function fetchNotificationPreferences() {
|
|||||||
|
|
||||||
export async function updateNotificationPreference(type, enabled) {
|
export async function updateNotificationPreference(type, enabled) {
|
||||||
try {
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return false
|
if (!token) return false
|
||||||
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {
|
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {
|
||||||
@@ -78,3 +109,235 @@ export async function updateNotificationPreference(type, enabled) {
|
|||||||
return false
|
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()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { API_BASE_URL } from '../main'
|
|
||||||
import { getToken } from './auth'
|
import { getToken } from './auth'
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String) {
|
function urlBase64ToUint8Array(base64String) {
|
||||||
@@ -21,6 +20,8 @@ function arrayBufferToBase64(buffer) {
|
|||||||
|
|
||||||
export async function registerPush() {
|
export async function registerPush() {
|
||||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
try {
|
try {
|
||||||
const reg = await navigator.serviceWorker.register('/notifications-sw.js')
|
const reg = await navigator.serviceWorker.register('/notifications-sw.js')
|
||||||
const res = await fetch(`${API_BASE_URL}/api/push/public-key`)
|
const res = await fetch(`${API_BASE_URL}/api/push/public-key`)
|
||||||
|
|||||||
+162
-12
@@ -14,19 +14,46 @@ export const themeState = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function apply(mode) {
|
function apply(mode) {
|
||||||
if (!process.client) return
|
if (!import.meta.client) return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (mode === ThemeMode.SYSTEM) {
|
let newMode =
|
||||||
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
mode === ThemeMode.SYSTEM
|
||||||
? 'dark'
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
: 'light'
|
? '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 {
|
} 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() {
|
export function initTheme() {
|
||||||
if (!process.client) return
|
if (!import.meta.client) return
|
||||||
const saved = localStorage.getItem(THEME_KEY)
|
const saved = localStorage.getItem(THEME_KEY)
|
||||||
if (saved && Object.values(ThemeMode).includes(saved)) {
|
if (saved && Object.values(ThemeMode).includes(saved)) {
|
||||||
themeState.mode = saved
|
themeState.mode = saved
|
||||||
@@ -35,15 +62,117 @@ export function initTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(mode) {
|
export function setTheme(mode) {
|
||||||
if (!process.client) return
|
if (!import.meta.client) return
|
||||||
if (!Object.values(ThemeMode).includes(mode)) return
|
if (!Object.values(ThemeMode).includes(mode)) return
|
||||||
themeState.mode = mode
|
themeState.mode = mode
|
||||||
localStorage.setItem(THEME_KEY, mode)
|
localStorage.setItem(THEME_KEY, mode)
|
||||||
apply(mode)
|
apply(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cycleTheme() {
|
function getCircle(event) {
|
||||||
if (!process.client) return
|
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 modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
||||||
const index = modes.indexOf(themeState.mode)
|
const index = modes.indexOf(themeState.mode)
|
||||||
const next = modes[(index + 1) % modes.length]
|
const next = modes[(index + 1) % modes.length]
|
||||||
@@ -54,10 +183,31 @@ export function cycleTheme() {
|
|||||||
} else {
|
} else {
|
||||||
toast.success('🌙 已经切换到暗色主题')
|
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', () => {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
if (themeState.mode === ThemeMode.SYSTEM) {
|
if (themeState.mode === ThemeMode.SYSTEM) {
|
||||||
apply(ThemeMode.SYSTEM)
|
apply(ThemeMode.SYSTEM)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { WEBSITE_BASE_URL } from '../constants'
|
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
@@ -22,6 +21,9 @@ async function generateCodeChallenge(codeVerifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function twitterAuthorize(state = '') {
|
export async function twitterAuthorize(state = '') {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
const TWITTER_CLIENT_ID = config.public.twitterClientId
|
||||||
if (!TWITTER_CLIENT_ID) {
|
if (!TWITTER_CLIENT_ID) {
|
||||||
toast.error('Twitter 登录不可用')
|
toast.error('Twitter 登录不可用')
|
||||||
return
|
return
|
||||||
@@ -42,6 +44,8 @@ export async function twitterAuthorize(state = '') {
|
|||||||
|
|
||||||
export async function twitterExchange(code, state, reason) {
|
export async function twitterExchange(code, state, reason) {
|
||||||
try {
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
||||||
sessionStorage.removeItem('twitter_code_verifier')
|
sessionStorage.removeItem('twitter_code_verifier')
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { API_BASE_URL } from '../main'
|
|
||||||
|
|
||||||
export async function fetchFollowings(username) {
|
export async function fetchFollowings(username) {
|
||||||
if (!username) return []
|
if (!username) return []
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
||||||
return res.ok ? await res.json() : []
|
return res.ok ? await res.json() : []
|
||||||
@@ -11,6 +11,8 @@ export async function fetchFollowings(username) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdmins() {
|
export async function fetchAdmins() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
|
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
|
||||||
return res.ok ? await res.json() : []
|
return res.ok ? await res.json() : []
|
||||||
@@ -21,6 +23,8 @@ export async function fetchAdmins() {
|
|||||||
|
|
||||||
export async function searchUsers(keyword) {
|
export async function searchUsers(keyword) {
|
||||||
if (!keyword) return []
|
if (!keyword) return []
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`,
|
`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Vditor from 'vditor'
|
import Vditor from 'vditor'
|
||||||
import { API_BASE_URL } from '../main'
|
|
||||||
import { getToken, authState } from './auth'
|
import { getToken, authState } from './auth'
|
||||||
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
|
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
|
||||||
import { tiebaEmoji } from './tiebaEmoji'
|
import { tiebaEmoji } from './tiebaEmoji'
|
||||||
@@ -14,6 +13,8 @@ export function getPreviewTheme() {
|
|||||||
|
|
||||||
export function createVditor(editorId, options = {}) {
|
export function createVditor(editorId, options = {}) {
|
||||||
const { placeholder = '', preview = {}, input, after } = options
|
const { placeholder = '', preview = {}, input, after } = options
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const fetchMentions = async (value) => {
|
const fetchMentions = async (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user