mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Compare commits
52 Commits
codex/fix-
...
codex/adap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba5f0148af | ||
|
|
cfe24b5e8e | ||
|
|
52633c8073 | ||
|
|
4802c78156 | ||
|
|
cf2299f9bf | ||
|
|
f03bf92641 | ||
|
|
8bb9c3e3d9 | ||
|
|
8c554465f6 | ||
|
|
05d56df44e | ||
|
|
5b0cbe8ce9 | ||
|
|
140d33d024 | ||
|
|
6ad7e951fe | ||
|
|
da47d37dc5 | ||
|
|
6293f572d8 | ||
|
|
94f4792a32 | ||
|
|
069f4bb8c1 | ||
|
|
7421ec8984 | ||
|
|
90b9d75da2 | ||
|
|
d69b094a7b | ||
|
|
67d80a4edd | ||
|
|
78498c0ac3 | ||
|
|
47c997ad22 | ||
|
|
2cd220e8eb | ||
|
|
8023fa1810 | ||
|
|
04b1b32b9c | ||
|
|
f5d8f37f96 | ||
|
|
4a4c256568 | ||
|
|
3bb14ca6a3 | ||
|
|
4ed679c4f4 | ||
|
|
50848e0da1 | ||
|
|
51819913a0 | ||
|
|
741bd115d5 | ||
|
|
d13ee2257f | ||
|
|
06dea47bec | ||
|
|
f89a17f14d | ||
|
|
ac433d6a45 | ||
|
|
62e7795e11 | ||
|
|
722d784691 | ||
|
|
5dab838482 | ||
|
|
67636475aa | ||
|
|
92ae8ae155 | ||
|
|
c0afe9e2a9 | ||
|
|
2c1bef4551 | ||
|
|
202c0f7b59 | ||
|
|
fdd6587fff | ||
|
|
77ea208961 | ||
|
|
96e1259ad7 | ||
|
|
b77b629d9e | ||
|
|
2e2813bcbd | ||
|
|
ad079e6bfd | ||
|
|
6f30cf0bc2 | ||
|
|
931aee4c3f |
29
.gitignore
vendored
29
.gitignore
vendored
@@ -1,7 +1,30 @@
|
||||
# IDE
|
||||
.idea
|
||||
target
|
||||
openisle.iml
|
||||
|
||||
# log
|
||||
logs
|
||||
|
||||
# deps
|
||||
node_modules
|
||||
|
||||
# test & build
|
||||
coverage
|
||||
out/
|
||||
build
|
||||
dist
|
||||
open-isle.env
|
||||
logs
|
||||
*.tsbuildinfo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# env
|
||||
*.env
|
||||
.env*.local
|
||||
|
||||
# others
|
||||
openisle.iml
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
- [前置工作](#前置工作)
|
||||
- [启动后端服务](#启动后端服务)
|
||||
- [本地 IDEA](#本地-idea)
|
||||
- [环境变量配置](#环境变量配置)
|
||||
- [配置参数](#配置参数)
|
||||
- [配置环境变量](#配置环境变量)
|
||||
- [配置 IDEA 参数](#配置-idea-参数)
|
||||
- [配置 MySQL](#配置-mysql)
|
||||
- [Docker 环境](#docker-环境)
|
||||
- [环境变量配置](#环境变量配置-1)
|
||||
- [配置环境变量](#配置环境变量-1)
|
||||
- [构建并启动镜像](#构建并启动镜像)
|
||||
- [启动前端服务](#启动前端服务)
|
||||
- [环境变量配置](#环境变量配置-2)
|
||||
- [配置环境变量](#配置环境变量-2)
|
||||
- [安装依赖和运行](#安装依赖和运行)
|
||||
- [其他配置](#其他配置)
|
||||
|
||||
@@ -21,6 +21,11 @@ git clone https://github.com/nagisa77/OpenIsle.git
|
||||
cd OpenIsle
|
||||
```
|
||||
|
||||
- 后端开发环境
|
||||
- JDK 17+
|
||||
- 前端开发环境
|
||||
- Node.JS 20+
|
||||
|
||||
## 启动后端服务
|
||||
|
||||
启动后端服务有多种方式,选择一种即可。
|
||||
@@ -30,9 +35,13 @@ cd OpenIsle
|
||||
|
||||
### 本地 IDEA
|
||||
|
||||
```shell
|
||||
cd backend/
|
||||
```
|
||||
|
||||
IDEA 打开 `backend/` 文件夹。
|
||||
|
||||
#### 环境变量配置
|
||||
#### 配置环境变量
|
||||
|
||||
1. 生成环境变量文件
|
||||
|
||||
@@ -58,7 +67,7 @@ SERVER_PORT=8082
|
||||
|
||||

|
||||
|
||||
#### 配置参数
|
||||
#### 配置 IDEA 参数
|
||||
|
||||
- 设置 JDK 版本为 java 17
|
||||
- 设置 VM Option,最好运行在其他端口,非 `8080`,这里设置 `8081`
|
||||
@@ -73,14 +82,14 @@ SERVER_PORT=8082
|
||||
|
||||
#### 配置 MySQL
|
||||
|
||||
> [!TIP]
|
||||
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
|
||||
|
||||
1. 本机配置 MySQL 服务(网上很多教程,忽略)
|
||||
|
||||
+ 可以用 Laragon,自带 MySQL 包括 Nodejs,版本建议 `6.x`,`7` 以后需要 Lisence
|
||||
+ [下载地址](https://github.com/leokhoa/laragon/releases)
|
||||
|
||||
> [!TIP]
|
||||
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
|
||||
|
||||
2. 填写环境变量
|
||||
|
||||

|
||||
@@ -91,7 +100,7 @@ SERVER_PORT=8082
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
```
|
||||
|
||||
3. 执行 db/init.sql 脚本,导入基本的数据
|
||||
3. 执行 [`db/init/init_script.sql`](backend/src/main/resources/db/init/init_script.sql) 脚本,导入基本的数据
|
||||
|
||||

|
||||
|
||||
@@ -100,30 +109,57 @@ SERVER_PORT=8082
|
||||

|
||||
|
||||
### Docker 环境
|
||||
#### 环境变量配置
|
||||
#### 配置环境变量
|
||||
|
||||
同上,见 [环境变量配置](#环境变量配置)
|
||||
```shell
|
||||
cd docker/
|
||||
```
|
||||
|
||||
主要配置两个 `.env` 文件
|
||||
|
||||
- `backend/open-isle.env`:后端环境变量,配置同上,见 [配置环境变量](#配置环境变量)。
|
||||
- `docker/.env`:Docker Compose 环境变量,主要配置 MySQL 相关
|
||||
```shell
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 使用单独的 `.env` 文件是为了兼容线上环境或已启用 MySQL 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。
|
||||
|
||||
在指定 `docker/.env` 后,`backend/open-isle.env` 中以下配置会被覆盖,这样就确保使用了同一份配置。
|
||||
|
||||
```ini
|
||||
MYSQL_URL=
|
||||
MYSQL_USER=
|
||||
MYSQL_PASSWORD=
|
||||
```
|
||||
|
||||
#### 构建并启动镜像
|
||||
|
||||
```shell
|
||||
cd docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
如果想了解启动过程发生了什么可以查看日志
|
||||
|
||||
```shell
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
## 启动前端服务
|
||||
|
||||
**⚠️ 环境要求:Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
|
||||
> [!IMPORTANT]
|
||||
> **⚠️ 环境要求:Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
|
||||
|
||||
```shell
|
||||
cd frontend_nuxt/
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
### 配置环境变量
|
||||
|
||||
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口。
|
||||
|
||||
- 利用预发环境:**(⚠️强烈推荐只部署前端的朋友使用该环境)**
|
||||
- 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)**
|
||||
|
||||
```shell
|
||||
cp .env.staging.example .env
|
||||
@@ -143,7 +179,7 @@ cd frontend_nuxt/
|
||||
|
||||
### 安装依赖和运行
|
||||
|
||||
前端安装编译并启动服务
|
||||
前端安装依赖并启动服务。
|
||||
|
||||
```shell
|
||||
# 安装依赖
|
||||
@@ -153,11 +189,11 @@ npm install --verbose
|
||||
npm run dev
|
||||
```
|
||||
|
||||
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面
|
||||
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面。
|
||||
|
||||
## 其他配置
|
||||
|
||||
配置第三方登录,这里以 GitHub 为例
|
||||
配置第三方登录,这里以 GitHub 为例:
|
||||
|
||||
- 修改 `application.properties` 配置
|
||||
|
||||
@@ -169,6 +205,6 @@ npm run dev
|
||||
|
||||
- 配置第三方登录回调地址
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
SERVER_PORT=8080
|
||||
|
||||
# === Database ===
|
||||
MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
|
||||
MYSQL_URL=jdbc:mysql://<数据库地址>:<数据库端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
|
||||
MYSQL_USER=<数据库用户名>
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
MYSQL_ROOT_PASSWORD=<数据库 root 用户密码>
|
||||
|
||||
# === JWT ===
|
||||
JWT_SECRET=<jwt secret>
|
||||
|
||||
@@ -30,6 +30,19 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-hibernate6</artifactId>
|
||||
<version>2.20.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
@@ -161,7 +174,7 @@
|
||||
</executions>
|
||||
<configuration>
|
||||
<!-- 此处为硬编码,应优化为 env 的配置 -->
|
||||
<apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
|
||||
<apiDocsUrl>http://localhost:8080/api/v3/api-docs</apiDocsUrl>
|
||||
<outputFileName>openapi.json</outputFileName>
|
||||
<outputDir>${project.build.directory}</outputDir>
|
||||
</configuration>
|
||||
|
||||
109
backend/src/main/java/com/openisle/config/CachingConfig.java
Normal file
109
backend/src/main/java/com/openisle/config/CachingConfig.java
Normal file
@@ -0,0 +1,109 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||||
import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
||||
import org.springframework.data.redis.serializer.RedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 缓存配置类
|
||||
* @author smallclover
|
||||
* @since 2025-09-04
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CachingConfig {
|
||||
|
||||
// 标签缓存名
|
||||
public static final String TAG_CACHE_NAME="openisle_tags";
|
||||
// 分类缓存名
|
||||
public static final String CATEGORY_CACHE_NAME="openisle_categories";
|
||||
|
||||
/**
|
||||
* 自定义Redis的序列化器
|
||||
* @return
|
||||
*/
|
||||
@Bean()
|
||||
@Primary
|
||||
public RedisSerializer<Object> redisSerializer() {
|
||||
// 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误,同时还要引入jsr310
|
||||
|
||||
// org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default:
|
||||
// add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
|
||||
// (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"])
|
||||
// 设置可见性,允许序列化所有元素
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
// Hibernate6Module 可以自动处理懒加载代理对象。
|
||||
// Tag对象的creator是FetchType.LAZY
|
||||
objectMapper.registerModule(new Hibernate6Module()
|
||||
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION));
|
||||
// service的时候带上类型信息
|
||||
// 启用类型信息,避免 LinkedHashMap 问题
|
||||
objectMapper.activateDefaultTyping(
|
||||
LaissezFaireSubTypeValidator.instance,
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
JsonTypeInfo.As.PROPERTY
|
||||
);
|
||||
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||
return new GenericJackson2JsonRedisSerializer(objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 Spring Cache 使用 RedisCacheManager
|
||||
*/
|
||||
@Bean
|
||||
public CacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
|
||||
|
||||
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(Duration.ZERO) // 默认缓存不过期
|
||||
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
|
||||
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
|
||||
.disableCachingNullValues(); // 禁止缓存 null 值
|
||||
// 个别缓存单独设置TTL时间
|
||||
// Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
|
||||
// cacheConfigs.put("openisle_tags", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ZERO));
|
||||
// cacheConfigs.put("openisle_categories", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ZERO));
|
||||
|
||||
return RedisCacheManager.builder(connectionFactory)
|
||||
.cacheDefaults(config)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 RedisTemplate,支持直接操作 Redis
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// key 和 hashKey 使用 String 序列化
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// value 和 hashValue 使用 JSON 序列化
|
||||
template.setValueSerializer(redisSerializer);
|
||||
template.setHashValueSerializer(redisSerializer);
|
||||
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Logs a message when a Redis connection is successfully established.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class RedisConnectionLogger implements InitializingBean {
|
||||
|
||||
private final RedisConnectionFactory connectionFactory;
|
||||
|
||||
public RedisConnectionLogger(RedisConnectionFactory connectionFactory) {
|
||||
this.connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
try (var connection = connectionFactory.getConnection()) {
|
||||
connection.ping();
|
||||
if (connectionFactory instanceof LettuceConnectionFactory lettuce) {
|
||||
log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort());
|
||||
} else {
|
||||
log.info("Redis connection established");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to connect to Redis", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ public class SecurityConfig {
|
||||
"http://30.211.97.238",
|
||||
"http://192.168.7.98",
|
||||
"http://192.168.7.98:3000",
|
||||
"https://petstore.swagger.io",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
@@ -110,7 +111,7 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
|
||||
.requestMatchers("/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/api/v3/api-docs/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
|
||||
@@ -182,7 +183,7 @@ public class SecurityConfig {
|
||||
}
|
||||
} else if (!uri.startsWith("/api/auth") && !publicGet
|
||||
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")
|
||||
&& !uri.startsWith("/v3/api-docs")) {
|
||||
&& !uri.startsWith("/api/v3/api-docs")) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Missing token\"}");
|
||||
|
||||
@@ -38,8 +38,8 @@ public class Tag {
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
// 改用redis缓存之后选择立即加载策略
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "creator_id")
|
||||
private User creator;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
@@ -11,7 +14,7 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
private final CategoryRepository categoryRepository;
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
||||
Category category = new Category();
|
||||
category.setName(name);
|
||||
@@ -20,7 +23,7 @@ public class CategoryService {
|
||||
category.setSmallIcon(smallIcon);
|
||||
return categoryRepository.save(category);
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public Category updateCategory(Long id, String name, String description, String icon, String smallIcon) {
|
||||
Category category = categoryRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
@@ -38,7 +41,7 @@ public class CategoryService {
|
||||
}
|
||||
return categoryRepository.save(category);
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public void deleteCategory(Long id) {
|
||||
categoryRepository.deleteById(id);
|
||||
}
|
||||
@@ -48,6 +51,14 @@ public class CategoryService {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 该方法每次首页加载都会访问,加入缓存
|
||||
* @return
|
||||
*/
|
||||
@Cacheable(
|
||||
value = CachingConfig.CATEGORY_CACHE_NAME,
|
||||
key = "'listCategories:'"
|
||||
)
|
||||
public List<Category> listCategories() {
|
||||
return categoryRepository.findAll();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -18,6 +21,7 @@ public class TagService {
|
||||
private final TagValidator tagValidator;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved, String creatorUsername) {
|
||||
tagValidator.validate(name);
|
||||
Tag tag = new Tag();
|
||||
@@ -42,6 +46,7 @@ public class TagService {
|
||||
return createTag(name, description, icon, smallIcon, true, null);
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) {
|
||||
Tag tag = tagRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
|
||||
@@ -61,6 +66,7 @@ public class TagService {
|
||||
return tagRepository.save(tag);
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public void deleteTag(Long id) {
|
||||
tagRepository.deleteById(id);
|
||||
}
|
||||
@@ -85,10 +91,20 @@ public class TagService {
|
||||
return tagRepository.findByApprovedTrue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 该方法每次首页加载都会访问,加入缓存
|
||||
* @param keyword
|
||||
* @return
|
||||
*/
|
||||
@Cacheable(
|
||||
value = CachingConfig.TAG_CACHE_NAME,
|
||||
key = "'searchTags:' + (#keyword ?: '')"//keyword为null的场合返回空
|
||||
)
|
||||
public List<Tag> searchTags(String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return tagRepository.findByApprovedTrue();
|
||||
}
|
||||
|
||||
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ spring.datasource.username=${MYSQL_USER:root}
|
||||
spring.datasource.password=${MYSQL_PASSWORD:password}
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
# for redis
|
||||
spring.data.redis.host=${REDIS_HOST:localhost}
|
||||
spring.data.redis.port=${REDIS_PORT:6379}
|
||||
spring.data.redis.database=0
|
||||
|
||||
# for jwt
|
||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||
@@ -99,7 +104,7 @@ rabbitmq.sharding.enabled=true
|
||||
|
||||
# springdoc-openapi-starter-webmvc-api
|
||||
# see https://springdoc.org/#springdoc-openapi-core-properties
|
||||
springdoc.api-docs.path=/v3/api-docs
|
||||
springdoc.api-docs.path=/api/v3/api-docs
|
||||
springdoc.api-docs.enabled=true
|
||||
springdoc.info.title=OpenIsle
|
||||
springdoc.info.description=OpenIsle Open API Documentation
|
||||
|
||||
@@ -2,14 +2,58 @@
|
||||
-- 本地化开发,初始化脚本
|
||||
-- 抽奖的时候奖品图片是必须的,把相关代码注释掉即可跳过check
|
||||
|
||||
-- 设置字符集和排序规则
|
||||
SET NAMES utf8;
|
||||
SET CHARACTER SET utf8;
|
||||
SET collation_connection = utf8_general_ci;
|
||||
|
||||
-- 创建 users 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`approved` bit(1) DEFAULT NULL,
|
||||
`avatar` varchar(255) DEFAULT NULL,
|
||||
`created_at` datetime(6) DEFAULT NULL,
|
||||
`display_medal` varchar(255) DEFAULT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`experience` int DEFAULT NULL,
|
||||
`introduction` text,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`password_reset_code` varchar(255) DEFAULT NULL,
|
||||
`point` int DEFAULT NULL,
|
||||
`register_reason` text,
|
||||
`role` varchar(20) DEFAULT 'USER',
|
||||
`username` varchar(50) NOT NULL,
|
||||
`verification_code` varchar(255) DEFAULT NULL,
|
||||
`verified` bit(1) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_users_email` (`email`),
|
||||
UNIQUE KEY `UK_users_username` (`username`)
|
||||
);
|
||||
|
||||
-- 清空users表
|
||||
DELETE FROM `users`;
|
||||
-- 插入用户,两个普通用户,一个管理员
|
||||
-- username:admin/user1/user2 password:123321
|
||||
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
|
||||
(1, b'1', '', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', '管理员', NULL, b'1'),
|
||||
(2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', '普通用户2', NULL, b'1'),
|
||||
(3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', '普通用户1', NULL, b'1');
|
||||
(1, b'1', '', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', 'admin', NULL, b'1'),
|
||||
(2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user1', NULL, b'1'),
|
||||
(3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user2', NULL, b'1');
|
||||
|
||||
-- 创建 tags 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS `tags` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`approved` bit(1) DEFAULT NULL,
|
||||
`created_at` datetime(6) DEFAULT NULL,
|
||||
`description` text,
|
||||
`icon` varchar(255) DEFAULT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`small_icon` varchar(255) DEFAULT NULL,
|
||||
`creator_id` bigint DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_tags_name` (`name`),
|
||||
KEY `FK_tags_creator` (`creator_id`),
|
||||
CONSTRAINT `FK_tags_creator` FOREIGN KEY (`creator_id`) REFERENCES `users` (`id`)
|
||||
);
|
||||
-- 清空tags表
|
||||
DELETE FROM `tags`;
|
||||
-- 插入标签,三个测试用标签
|
||||
@@ -18,6 +62,16 @@ INSERT INTO `tags` (`id`, `approved`, `created_at`, `description`, `icon`, `name
|
||||
(2, b'1', '2025-09-02 10:51:56.000000', '测试用标签2', NULL, '测试用标签2', NULL, NULL),
|
||||
(3, b'1', '2025-09-02 10:51:56.000000', '测试用标签3', NULL, '测试用标签3', NULL, NULL);
|
||||
|
||||
-- 创建 categories 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS `categories` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`description` text,
|
||||
`icon` varchar(255) DEFAULT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`small_icon` varchar(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_categories_name` (`name`)
|
||||
);
|
||||
-- 清空categories表
|
||||
DELETE FROM `categories`;
|
||||
-- 插入分类,三个测试用分类
|
||||
|
||||
11
docker/.env.example
Normal file
11
docker/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# 前端访问端口
|
||||
SERVER_PORT=8080
|
||||
|
||||
# MySQL 配置
|
||||
MYSQL_ROOT_PASSWORD=toor
|
||||
|
||||
# 会覆盖 `open-isle.env`
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=openisle
|
||||
MYSQL_USER=<数据库用户名>
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
@@ -6,11 +6,12 @@ services:
|
||||
restart: always
|
||||
env_file:
|
||||
- ../backend/open-isle.env
|
||||
- ./.env
|
||||
ports:
|
||||
- '3306:3306'
|
||||
- "${MYSQL_PORT}:3306"
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ../backend/src/main/resources/db/init/init_script.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- openisle-network
|
||||
|
||||
@@ -19,13 +20,16 @@ services:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: openisle-springboot
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ../backend/open-isle.env
|
||||
- ./.env
|
||||
environment:
|
||||
- MYSQL_URL=jdbc:mysql://mysql:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
|
||||
ports:
|
||||
- "${SERVER_PORT}:8080"
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
- maven-repo:/root/.m2
|
||||
ports:
|
||||
- '8080:8080'
|
||||
env_file:
|
||||
- ../backend/open-isle.env
|
||||
depends_on:
|
||||
- mysql
|
||||
command: mvn clean spring-boot:run -Dmaven.test.skip=true
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
class="app-new-post-icon"
|
||||
@click="goToNewPost"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
<edit />
|
||||
</div>
|
||||
</div>
|
||||
<GlobalPopups />
|
||||
|
||||
@@ -162,6 +162,9 @@ body {
|
||||
padding-left: 1em;
|
||||
border-left: 4px solid #d0d7de;
|
||||
color: var(--blockquote-text-color);
|
||||
background-color: var(--menu-selected-background-color);
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="base-input">
|
||||
<i v-if="icon" :class="['base-input-icon', icon]" />
|
||||
<component v-if="icon" :is="icon" class="base-input-icon" size="14" />
|
||||
|
||||
<!-- 普通输入框 -->
|
||||
<input
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
icon: { type: [String, Object], default: '' },
|
||||
type: { type: String, default: 'text' },
|
||||
textarea: { type: Boolean, default: false },
|
||||
},
|
||||
@@ -66,7 +66,6 @@ export default {
|
||||
|
||||
.base-input-icon {
|
||||
opacity: 0.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.base-input-text {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="base-placeholder">
|
||||
<i :class="['base-placeholder-icon', icon]" />
|
||||
<component :is="icon" class="base-placeholder-icon" theme="outline" />
|
||||
<div class="base-placeholder-text">
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
name: 'BasePlaceholder',
|
||||
props: {
|
||||
text: { type: String, default: '' },
|
||||
icon: { type: String, default: 'fas fa-inbox' },
|
||||
icon: { type: String, default: 'inbox' },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -27,6 +27,7 @@ export default {
|
||||
height: 300px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.base-placeholder-icon,
|
||||
.base-placeholder-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
:class="['base-tabs-item', { selected: modelValue === tab.key }]"
|
||||
@click="$emit('update:modelValue', tab.key)"
|
||||
>
|
||||
<i v-if="tab.icon" :class="tab.icon"></i>
|
||||
<component
|
||||
v-if="tab.icon && (typeof tab.icon !== 'string' || !tab.icon.includes(' '))"
|
||||
:is="tab.icon"
|
||||
class="base-tabs-item-icon"
|
||||
/>
|
||||
<div class="base-tabs-item-label">{{ tab.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,6 +76,7 @@ function onTouchEnd(e) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.base-tabs-item-icon,
|
||||
.base-tabs-item i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
@click="item.iconClick && item.iconClick()"
|
||||
>
|
||||
<BaseImage v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
|
||||
<i v-else-if="item.icon" :class="item.icon"></i>
|
||||
<component
|
||||
v-else-if="item.icon && (typeof item.icon !== 'string' || !item.icon.includes(' '))"
|
||||
:is="item.icon"
|
||||
:size="20"
|
||||
/>
|
||||
<BaseImage v-else-if="item.emoji" :src="item.emoji" class="timeline-emoji" alt="emoji" />
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
class="option-icon"
|
||||
:alt="option.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
<!-- <i v-else :class="['option-icon', option.icon]"></i> -->
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="comment-bottom-container">
|
||||
<div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||
<template v-if="!loading"> 发布评论 </template>
|
||||
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发布中... </template>
|
||||
<template v-else> <loading-four /> 发布中... </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,19 +15,23 @@
|
||||
<div class="common-info-content-header">
|
||||
<div class="info-content-header-left">
|
||||
<span class="user-name">{{ comment.userName }}</span>
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<medal-one class="medal-icon" />
|
||||
<NuxtLink
|
||||
v-if="comment.medal"
|
||||
class="medal-name"
|
||||
:to="`/users/${comment.userId}?tab=achievements`"
|
||||
>{{ getMedalTitle(comment.medal) }}</NuxtLink
|
||||
>
|
||||
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
|
||||
<pin v-if="comment.pinned" class="pin-icon" />
|
||||
<span v-if="level >= 2" class="reply-item">
|
||||
<i class="fas fa-reply reply-icon"></i>
|
||||
<next class="reply-icon" />
|
||||
<span class="reply-info">
|
||||
<BaseImage class="reply-avatar" :src="comment.parentUserAvatar || '/default-avatar.svg'" alt="avatar"
|
||||
@click="comment.parentUserClick && comment.parentUserClick()" />
|
||||
<BaseImage
|
||||
class="reply-avatar"
|
||||
:src="comment.parentUserAvatar || '/default-avatar.svg'"
|
||||
alt="avatar"
|
||||
@click="comment.parentUserClick && comment.parentUserClick()"
|
||||
/>
|
||||
<span class="reply-user-name">{{ comment.parentUserName }}</span>
|
||||
</span>
|
||||
</span>
|
||||
@@ -36,7 +40,7 @@
|
||||
<div class="info-content-header-right">
|
||||
<DropdownMenu v-if="commentMenuItems.length > 0" :items="commentMenuItems">
|
||||
<template #trigger>
|
||||
<i class="fas fa-ellipsis-vertical action-menu-icon"></i>
|
||||
<more-one class="action-menu-icon" />
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -49,10 +53,10 @@
|
||||
<div class="article-footer-container">
|
||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
||||
<i class="far fa-comment"></i>
|
||||
<comment-icon />
|
||||
</div>
|
||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
||||
<i class="fas fa-link"></i>
|
||||
<link-icon />
|
||||
</div>
|
||||
</ReactionsGroup>
|
||||
</div>
|
||||
@@ -67,8 +71,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-if="replyCount && level < 2" class="reply-toggle" @click="toggleReplies">
|
||||
<i v-if="showReplies" class="fas fa-chevron-up reply-toggle-icon"></i>
|
||||
<i v-else class="fas fa-chevron-down reply-toggle-icon"></i>
|
||||
<up v-if="showReplies" class="reply-toggle-icon" />
|
||||
<down v-else class="reply-toggle-icon" />
|
||||
{{ replyCount }}条回复
|
||||
</div>
|
||||
<div v-if="showReplies && level < 2" class="reply-list">
|
||||
@@ -371,7 +375,6 @@ const handleContentClick = (e) => {
|
||||
}
|
||||
|
||||
.reply-toggle-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.common-info-content-header {
|
||||
@@ -381,7 +384,8 @@ const handleContentClick = (e) => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reply-item, .reply-info {
|
||||
.reply-item,
|
||||
.reply-info {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -397,13 +401,16 @@ const handleContentClick = (e) => {
|
||||
|
||||
.reply-icon {
|
||||
color: var(--primary-color);
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
opacity: 0.5;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.reply-user-name {
|
||||
opacity: 0.3;
|
||||
display: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.medal-name {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
class="option-icon"
|
||||
:alt="label.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', label.icon]"></i>
|
||||
<component v-else :is="label.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ label.name }}</span>
|
||||
</div>
|
||||
@@ -38,14 +38,14 @@
|
||||
class="option-icon"
|
||||
:alt="selectedLabels[0].name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', selectedLabels[0].icon]"></i>
|
||||
<component v-else :is="selectedLabels[0].icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ selectedLabels[0].name }}</span>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else class="placeholder">{{ placeholder }}</span>
|
||||
</template>
|
||||
<i class="fas fa-caret-down dropdown-caret"></i>
|
||||
<down class="dropdown-caret" />
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
@@ -54,7 +54,7 @@
|
||||
v-click-outside="close"
|
||||
>
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<search-icon class="search-icon" />
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
@@ -75,7 +75,7 @@
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
@@ -85,12 +85,12 @@
|
||||
<Teleport to="body">
|
||||
<div v-if="open && isMobile" class="dropdown-mobile-page">
|
||||
<div class="dropdown-mobile-header">
|
||||
<i class="fas fa-arrow-left" @click="close"></i>
|
||||
<next class="back-icon" @click="close" />
|
||||
<span class="mobile-title">{{ placeholder }}</span>
|
||||
</div>
|
||||
<div class="dropdown-mobile-menu">
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<search-icon class="search-icon" />
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
@@ -111,7 +111,7 @@
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
@@ -303,6 +303,10 @@ export default {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.selected-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -326,6 +330,10 @@ export default {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -376,6 +384,11 @@ export default {
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
transform: rotate(180deg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-mobile-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="header-content-left">
|
||||
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
||||
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
|
||||
<i class="fas fa-bars micon"></i>
|
||||
<application-menu class="micon"></application-menu>
|
||||
</button>
|
||||
<span
|
||||
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
|
||||
@@ -25,34 +25,34 @@
|
||||
<ClientOnly>
|
||||
<div class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
<search-icon />
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
<component :is="iconClass" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
|
||||
<i class="fas fa-copy"></i>
|
||||
<copy />
|
||||
邀请
|
||||
<i v-if="isCopying" class="fas fa-spinner fa-spin"></i>
|
||||
<loading v-if="isCopying" />
|
||||
</div>
|
||||
|
||||
<ToolTip content="复制RSS链接" placement="bottom">
|
||||
<div class="rss-icon" @click="copyRssLink">
|
||||
<i class="fas fa-rss"></i>
|
||||
<rss />
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||
<div class="new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
<edit />
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
||||
<div class="messages-icon" @click="goToMessages">
|
||||
<i class="fas fa-comments"></i>
|
||||
<message-emoji />
|
||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
||||
unreadMessageCount
|
||||
}}</span>
|
||||
@@ -64,7 +64,7 @@
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||
<down />
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
@@ -226,11 +226,11 @@ const headerMenuItems = computed(() => [
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
return 'Moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
return 'SunOne'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
return 'ComputerOne'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="invite-code-activity">
|
||||
<div class="invite-code-description">
|
||||
<div class="invite-code-description-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<info-icon />
|
||||
<span class="invite-code-description-title-text">邀请规则说明</span>
|
||||
</div>
|
||||
<div class="invite-code-description-content">
|
||||
@@ -17,7 +17,7 @@
|
||||
<div v-if="inviteLink" class="invite-code-link-content">
|
||||
<p class="invite-code-link-content-text">
|
||||
邀请链接:{{ inviteLink }}
|
||||
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
|
||||
<span @click="copyLink"><copy class="copy-icon" /></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="login-overlay">
|
||||
<div class="login-overlay-blur"></div>
|
||||
<div class="login-overlay-content">
|
||||
<i class="fa-solid fa-user login-overlay-icon"></i>
|
||||
<user-icon class="login-overlay-icon" />
|
||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<span class="prize-row-title">奖品图片</span>
|
||||
<label class="prize-container">
|
||||
<BaseImage v-if="data.prizeIcon" :src="data.prizeIcon" class="prize-preview" alt="prize" />
|
||||
<i v-else class="fa-solid fa-image default-prize-icon"></i>
|
||||
<image-files v-else class="default-prize-icon" />
|
||||
<div class="prize-overlay">上传奖品图片</div>
|
||||
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
|
||||
</label>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="menu-content">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<hashtag-key class="menu-item-icon" />
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -13,7 +13,7 @@
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<edit class="menu-item-icon" />
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -22,7 +22,7 @@
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<remind class="menu-item-icon" />
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
@@ -34,7 +34,7 @@
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<info-icon class="menu-item-icon" />
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -43,7 +43,7 @@
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<gift class="menu-item-icon" />
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -53,7 +53,7 @@
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<chart-line class="menu-item-icon" />
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -63,7 +63,7 @@
|
||||
to="/points"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-coins"></i>
|
||||
<finance class="menu-item-icon" />
|
||||
<span class="menu-item-text">
|
||||
积分商城
|
||||
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||
@@ -74,7 +74,8 @@
|
||||
<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>
|
||||
<up v-if="categoryOpen" class="menu-item-icon" />
|
||||
<down v-else class="menu-item-icon" />
|
||||
</div>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
@@ -94,7 +95,7 @@
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
<component v-else :is="c.smallIcon || c.icon" class="section-item-icon" />
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
@@ -107,7 +108,8 @@
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>标签</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
<up v-if="tagOpen" class="menu-item-icon" />
|
||||
<down v-else class="menu-item-icon" />
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
@@ -120,7 +122,7 @@
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<tag-one v-else class="section-item-icon" />
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
@@ -133,7 +135,7 @@
|
||||
<ClientOnly v-if="!isMobile">
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
<component :is="iconClass" class="menu-item-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
@@ -193,11 +195,11 @@ const {
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
return 'Moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
return 'SunOne'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
return 'ComputerOne'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="message-bottom-container">
|
||||
<div class="message-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||
<template v-if="!loading"> 发送 </template>
|
||||
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发送中... </template>
|
||||
<template v-else> <loading-four /> 发送中... </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,20 +3,10 @@
|
||||
<iframe :src="iframeSrc" frameborder="0" ref="iframeRef" @load="injectBaseTag"></iframe>
|
||||
|
||||
<div class="float-actions">
|
||||
<i
|
||||
class="fas fa-chevron-down"
|
||||
v-if="floatHeight !== MINI_HEIGHT"
|
||||
title="收起至 100px"
|
||||
@click="collapseToMini"
|
||||
></i>
|
||||
<i
|
||||
class="fas fa-chevron-up"
|
||||
v-if="floatHeight !== DEFAULT_HEIGHT"
|
||||
title="回弹至 60vh"
|
||||
@click="reboundToDefault"
|
||||
></i>
|
||||
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
|
||||
<i class="fas fa-times" title="关闭" @click="close"></i>
|
||||
<down v-if="floatHeight !== MINI_HEIGHT" title="收起至 100px" @click="collapseToMini" />
|
||||
<up v-if="floatHeight !== DEFAULT_HEIGHT" title="回弹至 60vh" @click="reboundToDefault" />
|
||||
<expand-up title="在页面中打开" @click="expand" />
|
||||
<close-icon title="关闭" @click="close" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="milk-tea-activity">
|
||||
<div class="milk-tea-description">
|
||||
<div class="milk-tea-description-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<info-icon />
|
||||
<span class="milk-tea-description-title-text">升级规则说明</span>
|
||||
</div>
|
||||
<div class="milk-tea-description-content">
|
||||
@@ -29,7 +29,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="user-level">
|
||||
<div class="user-level-text"><i class="fas fa-user-circle"></i> 请登录查看自身等级</div>
|
||||
<div class="user-level-text"><user-icon /> 请登录查看自身等级</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
:src="lottery.prizeIcon"
|
||||
alt="prize"
|
||||
/>
|
||||
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
|
||||
<gift v-else class="default-prize-icon" />
|
||||
</div>
|
||||
<div class="prize-name">{{ lottery.prizeDescription }}</div>
|
||||
<div class="prize-count">x {{ lottery.prizeCount }}</div>
|
||||
</div>
|
||||
<div class="prize-end-time prize-info-right">
|
||||
<i class="fas fa-stopwatch prize-end-time-icon"></i>
|
||||
<div v-if="!isMobile" class="prize-end-time-title">离结束</div>
|
||||
<stopwatch v-if="!lotteryEnded" class="prize-end-time-icon" />
|
||||
<div v-if="!isMobile && !lotteryEnded" class="prize-end-time-title">离结束</div>
|
||||
<div class="prize-end-time-value">{{ countdown }}</div>
|
||||
<div v-if="!isMobile" class="join-prize-button-container-desktop">
|
||||
<div
|
||||
@@ -26,7 +26,8 @@
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
参与抽奖 <paper-money-two class="join-prize-button-text-icon" />
|
||||
{{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
@@ -43,7 +44,7 @@
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
参与抽奖 <paper-money-two class="join-prize-button-text-icon" /> {{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
@@ -61,7 +62,7 @@
|
||||
@click="gotoUser(p.id)"
|
||||
/>
|
||||
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<medal-one class="medal-icon"></medal-one>
|
||||
<span class="prize-member-winner-name">获奖者: </span>
|
||||
<BaseImage
|
||||
v-for="w in lotteryWinners"
|
||||
@@ -193,6 +194,7 @@ const joinLottery = async () => {
|
||||
|
||||
.prize-end-time-icon {
|
||||
font-size: 13px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.prize-end-time-title {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="poll-option-title" v-else>单选</div>
|
||||
|
||||
<div class="poll-left-time">
|
||||
<i class="fas fa-stopwatch poll-left-time-icon"></i>
|
||||
<stopwatch class="poll-left-time-icon" />
|
||||
<div class="poll-left-time-title">离结束</div>
|
||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||
</div>
|
||||
@@ -55,9 +55,7 @@
|
||||
</div>
|
||||
|
||||
<div class="multi-selection-container">
|
||||
<div class="join-poll-button" @click="submitMultiPoll">
|
||||
<i class="fas fa-check"></i> 确认投票
|
||||
</div>
|
||||
<div class="join-poll-button" @click="submitMultiPoll"><check /> 确认投票</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -77,9 +75,7 @@
|
||||
</div>
|
||||
|
||||
<div class="single-selection-container">
|
||||
<div class="join-poll-button" @click="submitSinglePoll">
|
||||
<i class="fas fa-check"></i> 确认投票
|
||||
</div>
|
||||
<div class="join-poll-button" @click="submitSinglePoll"><check /> 确认投票</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -95,22 +91,20 @@
|
||||
class="poll-option-button"
|
||||
@click="showPollResult = false"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i> 投票
|
||||
<arrow-left /> 投票
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!pollEnded && !hasVoted"
|
||||
class="poll-option-button"
|
||||
@click="showPollResult = true"
|
||||
>
|
||||
<i class="fas fa-chart-bar"></i> 结果
|
||||
</div>
|
||||
<div v-else-if="pollEnded" class="poll-option-hint">
|
||||
<i class="fas fa-stopwatch"></i> 投票已结束
|
||||
<chart-histogram /> 结果
|
||||
</div>
|
||||
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
||||
<div v-else class="poll-option-hint">
|
||||
<div>您已投票,等待结束查看结果</div>
|
||||
<div class="poll-left-time">
|
||||
<i class="fas fa-stopwatch poll-left-time-icon"></i>
|
||||
<stopwatch class="poll-left-time-icon" />
|
||||
<div class="poll-left-time-title">离结束</div>
|
||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||
</div>
|
||||
|
||||
@@ -31,9 +31,9 @@ export default {
|
||||
|
||||
const fetchTypes = async () => {
|
||||
return [
|
||||
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
|
||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' },
|
||||
{ id: 'POLL', name: '投票帖子', icon: 'fa-solid fa-square-poll-vertical' },
|
||||
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
||||
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
</div>
|
||||
|
||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||
<i class="far fa-smile reactions-viewer-item-placeholder-icon"></i>
|
||||
<!-- <span class="reactions-viewer-item-placeholder-text">点击以表态</span> -->
|
||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="displayedReactions.length">
|
||||
@@ -42,8 +41,8 @@
|
||||
class="make-reaction-item like-reaction"
|
||||
@click="toggleReaction('LIKE')"
|
||||
>
|
||||
<i v-if="!userReacted('LIKE')" class="far fa-heart"></i>
|
||||
<i v-else class="fas fa-heart"></i>
|
||||
<like v-if="!userReacted('LIKE')" />
|
||||
<like v-else theme="filled" />
|
||||
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
>
|
||||
<template #display="{ setSearch }">
|
||||
<div class="search-input">
|
||||
<i class="search-input-icon fas fa-search"></i>
|
||||
<search-icon class="search-input-icon" />
|
||||
<input
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="search-option-item">
|
||||
<i :class="['result-icon', iconMap[option.type] || 'fas fa-question']"></i>
|
||||
<component :is="iconMap[option.type]" class="result-icon" />
|
||||
<div class="result-body">
|
||||
<div class="result-main" v-html="highlight(option.text)"></div>
|
||||
<div v-if="option.subText" class="result-sub" v-html="highlight(option.subText)"></div>
|
||||
@@ -83,11 +83,12 @@ const highlight = (text) => {
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
user: 'fas fa-user',
|
||||
post: 'fas fa-file-alt',
|
||||
comment: 'fas fa-comment',
|
||||
category: 'fas fa-folder',
|
||||
tag: 'fas fa-hashtag',
|
||||
user: 'UserIcon',
|
||||
post: 'FileText',
|
||||
post_title: 'FileText',
|
||||
comment: 'CommentIcon',
|
||||
category: 'Inbox',
|
||||
tag: 'TagOne',
|
||||
}
|
||||
|
||||
watch(selected, (val) => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
>
|
||||
<template #display="{ setSearch }">
|
||||
<div class="search-input">
|
||||
<i class="search-input-icon fas fa-search"></i>
|
||||
<search-icon class="search-input-icon" />
|
||||
<input
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
<template #option="{ option }">
|
||||
<div class="option-container">
|
||||
<div class="option-main">
|
||||
<template v-if="option.icon">
|
||||
<template v-if="option.smallIcon || option.icon">
|
||||
<BaseImage
|
||||
v-if="isImageIcon(option.icon)"
|
||||
:src="option.icon"
|
||||
v-if="isImageIcon(option.smallIcon || option.icon)"
|
||||
:src="option.smallIcon || option.icon"
|
||||
class="option-icon"
|
||||
:alt="option.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
<component v-else :is="option.smallIcon || option.icon" class="option-icon" />
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="fas fa-inbox" />
|
||||
<BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="inbox" />
|
||||
<div v-for="u in users" :key="u.id" class="user-item" @click="handleUserClick(u)">
|
||||
<BaseImage :src="u.avatar" alt="avatar" class="user-avatar" />
|
||||
<div class="user-info">
|
||||
|
||||
@@ -15,7 +15,12 @@ export default defineNuxtConfig({
|
||||
telegramBotId: process.env.NUXT_PUBLIC_TELEGRAM_BOT_ID || '',
|
||||
},
|
||||
},
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
css: [
|
||||
'vditor/dist/index.css',
|
||||
'~/assets/fonts.css',
|
||||
'~/assets/global.css',
|
||||
'@icon-park/vue-next/styles/index.css',
|
||||
],
|
||||
app: {
|
||||
pageTransition: { name: 'page', mode: 'out-in' },
|
||||
head: {
|
||||
@@ -72,11 +77,11 @@ export default defineNuxtConfig({
|
||||
rel: 'manifest',
|
||||
href: '/manifest.webmanifest',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
||||
referrerpolicy: 'no-referrer',
|
||||
},
|
||||
// {
|
||||
// rel: 'stylesheet',
|
||||
// href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
||||
// referrerpolicy: 'no-referrer',
|
||||
// },
|
||||
],
|
||||
},
|
||||
baseURL: '/',
|
||||
|
||||
17
frontend_nuxt/package-lock.json
generated
17
frontend_nuxt/package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "frontend_nuxt",
|
||||
"dependencies": {
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
@@ -25,6 +26,9 @@
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-flatpickr-component": "^12.0.0",
|
||||
"vue-toastification": "^2.0.0-rc.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -990,6 +994,19 @@
|
||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@icon-park/vue-next": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@icon-park/vue-next/-/vue-next-1.4.2.tgz",
|
||||
"integrity": "sha512-+QklF255wkfBOabY+xw6FAI0Bwln/RhdwCunNy/9sKdKuChtaU67QZqU67KGAvZUTeeBgsL+yaHHxqfQeGZXEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 8.0.0",
|
||||
"npm": ">= 5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"generate": "nuxt generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="activity-list-page-card-content">{{ a.content }}</div>
|
||||
<div class="activity-list-page-card-footer">
|
||||
<div class="activity-list-page-card-footer-start-time">
|
||||
<i class="fas fa-clock"></i>
|
||||
<stopwatch />
|
||||
<span>开始于 {{ TimeManager.format(a.startTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
<div class="forgot-page">
|
||||
<div class="forgot-content">
|
||||
<div class="forgot-title">找回密码</div>
|
||||
|
||||
|
||||
<div v-if="step === 0" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||
<BaseInput icon="mail" v-model="email" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
<div class="primary-button" @click="sendCode" v-if="!isSending">发送验证码</div>
|
||||
<div class="primary-button disabled" v-else>发送中...</div>
|
||||
</div>
|
||||
<div v-else-if="step === 1" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="code" placeholder="邮箱验证码" />
|
||||
<BaseInput icon="mail" v-model="code" placeholder="邮箱验证码" />
|
||||
<div class="primary-button" @click="verifyCode" v-if="!isVerifying">验证</div>
|
||||
<div class="primary-button disabled" v-else>验证中...</div>
|
||||
</div>
|
||||
<div v-else class="step-content">
|
||||
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="新密码" />
|
||||
<BaseInput icon="lock" v-model="password" type="password" placeholder="新密码" />
|
||||
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||
<div class="primary-button disabled" v-else>提交中...</div>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<info-icon />
|
||||
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,13 +68,10 @@
|
||||
>
|
||||
<div class="article-main-container">
|
||||
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||
<i v-if="article.type === 'LOTTERY'" class="fa-solid fa-gift lottery-icon"></i>
|
||||
<i
|
||||
v-else-if="article.type === 'POLL'"
|
||||
class="fa-solid fa-square-poll-vertical poll-icon"
|
||||
></i>
|
||||
<i v-if="!article.rssExcluded" class="fa-solid fa-star featured-icon"></i>
|
||||
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
||||
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
||||
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||
{{ article.title }}
|
||||
</NuxtLink>
|
||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||
@@ -141,7 +138,6 @@ import { getToken } from '~/utils/auth'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
useHead({
|
||||
title: 'OpenIsle - 全面开源的自由社区',
|
||||
meta: [
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
</div>
|
||||
|
||||
<div class="email-login-page-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="username" placeholder="邮箱/用户名" />
|
||||
<BaseInput icon="mail" v-model="username" placeholder="邮箱/用户名" />
|
||||
|
||||
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="密码" />
|
||||
<BaseInput icon="lock" v-model="password" type="password" placeholder="密码" />
|
||||
|
||||
<div v-if="!isWaitingForLogin" class="login-page-button-primary" @click="submitLogin">
|
||||
<div class="login-page-button-text">登录</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div v-else class="login-page-button-primary disabled">
|
||||
<div class="login-page-button-text">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<loading-four />
|
||||
登录中...
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@
|
||||
>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<info-icon />
|
||||
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<div v-if="!loading" class="chat-header">
|
||||
<div class="header-main">
|
||||
<div class="back-button" @click="goBack">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<arrow-left />
|
||||
</div>
|
||||
<h2 class="participant-name">
|
||||
{{ isChannel ? conversationName : otherParticipant?.username }}
|
||||
</h2>
|
||||
</div>
|
||||
<div v-if="!isFloatMode" class="float-control">
|
||||
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
|
||||
<collapse-text-input class="float-control-icon" @click="minimize" title="最小化" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
:content-id="item.id"
|
||||
@update:modelValue="(v) => (item.reactions = v)"
|
||||
>
|
||||
<i class="fas fa-reply reply-btn" @click="setReply(item)"> 写个回复...</i>
|
||||
<div class="reply-btn"><next @click="setReply(item)" /> 写个回复...</div>
|
||||
</ReactionsGroup>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
@@ -56,7 +56,7 @@
|
||||
<BasePlaceholder
|
||||
v-if="messages.length === 0"
|
||||
text="暂无会话,发送消息试试 🎉"
|
||||
icon="fas fa-inbox"
|
||||
icon="inbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div v-if="replyTo" class="active-reply">
|
||||
正在回复 {{ replyTo.sender.username }}:
|
||||
{{ stripMarkdownLength(replyTo.content, 50) }}
|
||||
<i class="fas fa-times close-reply" @click="replyTo = null"></i>
|
||||
<close-icon class="close-reply" @click="replyTo = null" />
|
||||
</div>
|
||||
<MessageEditor :loading="sending" @submit="sendMessage" />
|
||||
</div>
|
||||
@@ -351,9 +351,9 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
const subscribeToConversation = () => {
|
||||
if (!currentUser.value) return;
|
||||
if (!currentUser.value) return
|
||||
const destination = `/topic/conversation/${conversationId}`
|
||||
|
||||
|
||||
subscribe(destination, async (message) => {
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message.body)
|
||||
@@ -370,12 +370,12 @@ const subscribeToConversation = () => {
|
||||
|
||||
await markConversationAsRead()
|
||||
await nextTick()
|
||||
|
||||
|
||||
if (isUserNearBottom.value) {
|
||||
scrollToBottomSmooth()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse websocket message", e)
|
||||
console.error('Failed to parse websocket message', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -394,7 +394,7 @@ onActivated(async () => {
|
||||
await nextTick()
|
||||
scrollToBottomSmooth()
|
||||
updateNearBottom()
|
||||
|
||||
|
||||
if (isConnected.value) {
|
||||
// 如果已连接,重新订阅
|
||||
subscribeToConversation()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="messages-container">
|
||||
<div class="page-title">
|
||||
<i class="fas fa-comments"></i>
|
||||
<message-emoji />
|
||||
<span class="page-title-text">选择聊天</span>
|
||||
</div>
|
||||
<div v-if="!isFloatMode" class="float-control">
|
||||
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
|
||||
<collapse-text-input class="float-control-icon" @click="minimize" title="最小化" />
|
||||
</div>
|
||||
<BaseTabs v-model="activeTab" :tabs="tabs">
|
||||
<div v-if="activeTab === 'messages'">
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && conversations.length === 0" class="empty-container">
|
||||
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="fas fa-inbox" />
|
||||
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="inbox" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -73,7 +73,7 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="channels.length === 0" class="empty-container">
|
||||
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
|
||||
<BasePlaceholder text="暂无频道" icon="inbox" />
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
@@ -273,9 +273,9 @@ onActivated(async () => {
|
||||
})
|
||||
|
||||
const subscribeToUserMessages = () => {
|
||||
if (!currentUser.value) return;
|
||||
if (!currentUser.value) return
|
||||
const destination = `/topic/user/${currentUser.value.id}/messages`
|
||||
|
||||
|
||||
subscribe(destination, (message) => {
|
||||
if (activeTab.value === 'messages') {
|
||||
fetchConversations()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template #right>
|
||||
<div class="message-page-header-right">
|
||||
<div class="message-page-header-right-item" @click="markAllRead">
|
||||
<i class="fas fa-bolt message-page-header-right-item-button-icon"></i>
|
||||
<check-correct class="message-page-header-right-item-button-icon" />
|
||||
<span class="message-page-header-right-item-button-text"> 已读所有消息 </span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,8 +14,12 @@
|
||||
<div class="message-control-container">
|
||||
<div class="message-control-title">通知设置</div>
|
||||
<div class="message-control-item-container">
|
||||
<template v-for="pref in notificationPrefs">
|
||||
<div v-if="canShowNotification(pref.type)" :key="pref.type" class="message-control-item">
|
||||
<template v-for="pref in notificationPrefs">
|
||||
<div
|
||||
v-if="canShowNotification(pref.type)"
|
||||
:key="pref.type"
|
||||
class="message-control-item"
|
||||
>
|
||||
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
|
||||
<BaseSwitch
|
||||
:model-value="pref.enabled"
|
||||
@@ -47,7 +51,7 @@
|
||||
<BasePlaceholder
|
||||
v-else-if="notifications.length === 0"
|
||||
text="暂时没有消息 :)"
|
||||
icon="fas fa-inbox"
|
||||
icon="inbox"
|
||||
/>
|
||||
|
||||
<div class="timeline-container" v-if="notifications.length > 0">
|
||||
@@ -757,7 +761,12 @@ const formatType = (t) => {
|
||||
|
||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||
|
||||
const needAdminSet = new Set(['POST_REVIEW_REQUEST','REGISTER_REQUEST', 'POINT_REDEEM', 'ACTIVITY_REDEEM'])
|
||||
const needAdminSet = new Set([
|
||||
'POST_REVIEW_REQUEST',
|
||||
'REGISTER_REQUEST',
|
||||
'POINT_REDEEM',
|
||||
'ACTIVITY_REDEEM',
|
||||
])
|
||||
|
||||
const canShowNotification = (type) => {
|
||||
return !needAdminSet.has(type) || isAdmin.value
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
<PostTypeSelect v-model="postType" />
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><i class="fa-solid fa-eraser"></i> 清空</div>
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
<div class="ai-generate" @click="aiGenerate">
|
||||
<i class="fa-solid fa-robot"></i>
|
||||
<smart-optimization />
|
||||
MD 格式优化
|
||||
</div>
|
||||
<div class="post-draft" @click="saveDraft">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
<save-icon />
|
||||
存草稿
|
||||
</div>
|
||||
<div
|
||||
@@ -30,9 +30,7 @@
|
||||
>
|
||||
发布
|
||||
</div>
|
||||
<div v-else class="post-submit-loading">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> 发布中...
|
||||
</div>
|
||||
<div v-else class="post-submit-loading"><loading-four /> 发布中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span
|
||||
<span><paper-money-two class="coin-icon" /></span>我的积分:<span
|
||||
class="point-value"
|
||||
>{{ point }}</span
|
||||
>
|
||||
@@ -37,7 +37,7 @@
|
||||
<BaseImage class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
<paper-money-two />
|
||||
{{ good.cost }} 积分
|
||||
</div>
|
||||
<div
|
||||
@@ -63,11 +63,7 @@
|
||||
<div class="loading-points-container" v-if="historyLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-else-if="histories.length === 0"
|
||||
text="暂无积分记录"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
<BasePlaceholder v-else-if="histories.length === 0" text="暂无积分记录" icon="inbox" />
|
||||
<div class="timeline-container" v-else>
|
||||
<BaseTimeline :items="histories">
|
||||
<template #item="{ item }">
|
||||
@@ -189,7 +185,7 @@
|
||||
参与,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
|
||||
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
||||
</div>
|
||||
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
|
||||
</template>
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><i class="fa-solid fa-eraser"></i> 清空</div>
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
<div class="ai-generate" @click="aiGenerate">
|
||||
<i class="fa-solid fa-robot"></i>
|
||||
<smart-optimization />
|
||||
MD 格式优化
|
||||
</div>
|
||||
<div class="post-cancel" @click="cancelEdit">取消</div>
|
||||
@@ -26,9 +26,7 @@
|
||||
>
|
||||
更新
|
||||
</div>
|
||||
<div v-else class="post-submit-loading">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> 更新中...
|
||||
</div>
|
||||
<div v-else class="post-submit-loading"><loading-four /> 更新中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
class="article-subscribe-button"
|
||||
@click="subscribePost"
|
||||
>
|
||||
<i class="fas fa-user-plus"></i>
|
||||
<people-plus />
|
||||
<div class="article-subscribe-button-text">
|
||||
{{ isMobile ? '订阅' : '订阅文章' }}
|
||||
</div>
|
||||
@@ -32,14 +32,14 @@
|
||||
class="article-unsubscribe-button"
|
||||
@click="unsubscribePost"
|
||||
>
|
||||
<i class="fas fa-user-minus"></i>
|
||||
<people-minus-one />
|
||||
<div class="article-unsubscribe-button-text">
|
||||
{{ isMobile ? '退订' : '取消订阅' }}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu v-if="articleMenuItems.length > 0" :items="articleMenuItems">
|
||||
<template #trigger>
|
||||
<i class="fas fa-ellipsis-vertical action-menu-icon"></i>
|
||||
<more-one class="action-menu-icon" />
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@
|
||||
<div v-if="isMobile" class="info-content-header">
|
||||
<div class="user-name">
|
||||
{{ author.username }}
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<medal-one class="medal-icon" />
|
||||
<NuxtLink
|
||||
v-if="author.displayMedal"
|
||||
class="user-medal"
|
||||
@@ -69,7 +69,7 @@
|
||||
<div v-if="!isMobile" class="info-content-header">
|
||||
<div class="user-name">
|
||||
{{ author.username }}
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<medal-one class="medal-icon" />
|
||||
<NuxtLink
|
||||
v-if="author.displayMedal"
|
||||
class="user-medal"
|
||||
@@ -88,7 +88,7 @@
|
||||
<div class="article-footer-container">
|
||||
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
|
||||
<div class="make-reaction-item copy-link" @click="copyPostLink">
|
||||
<i class="fas fa-link"></i>
|
||||
<link-icon />
|
||||
</div>
|
||||
</ReactionsGroup>
|
||||
</div>
|
||||
@@ -707,8 +707,8 @@ const unsubscribePost = async () => {
|
||||
|
||||
const fetchCommentSorts = () => {
|
||||
return Promise.resolve([
|
||||
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
|
||||
{ id: 'OLDEST', name: '最旧', icon: 'fas fa-hourglass-start' },
|
||||
{ id: 'NEWEST', name: '最新', icon: 'lightning' },
|
||||
{ id: 'OLDEST', name: '最旧', icon: 'history-icon' },
|
||||
// { id: 'MOST_INTERACTIONS', name: '最多互动', icon: 'fas fa-fire' }
|
||||
])
|
||||
}
|
||||
@@ -786,7 +786,6 @@ onMounted(async () => {
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@crop="onCropped"
|
||||
/>
|
||||
<div v-if="isLoadingPage" class="loading-page">
|
||||
<l-hatch size="20" 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 v-else>
|
||||
<div class="settings-title">个人资料设置</div>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div class="form-row username-row">
|
||||
<BaseInput
|
||||
icon="fas fa-user"
|
||||
icon="user-icon"
|
||||
v-model="username"
|
||||
@input="usernameError = ''"
|
||||
placeholder="用户名"
|
||||
@@ -133,15 +133,15 @@ const onCropped = ({ file, url }) => {
|
||||
}
|
||||
const fetchPublishModes = () => {
|
||||
return Promise.resolve([
|
||||
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
|
||||
{ id: 'DIRECT', name: '直接发布', icon: 'send-icon' },
|
||||
{ id: 'REVIEW', name: '审核后发布', icon: 'search-icon' },
|
||||
])
|
||||
}
|
||||
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' },
|
||||
{ id: 'LOW', name: '低', icon: 'unlock' },
|
||||
{ id: 'MEDIUM', name: '中', icon: 'lock-one' },
|
||||
{ id: 'HIGH', name: '高', icon: 'lock' },
|
||||
])
|
||||
}
|
||||
const fetchAiLimits = () => {
|
||||
@@ -154,8 +154,8 @@ const fetchAiLimits = () => {
|
||||
}
|
||||
const fetchRegisterModes = () => {
|
||||
return Promise.resolve([
|
||||
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
||||
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
|
||||
{ id: 'DIRECT', name: '直接注册', icon: 'send-icon' },
|
||||
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'search-icon' },
|
||||
])
|
||||
}
|
||||
const loadAdminConfig = async () => {
|
||||
|
||||
@@ -6,16 +6,11 @@
|
||||
</div>
|
||||
|
||||
<div v-if="emailStep === 0" class="email-signup-page-content">
|
||||
<BaseInput
|
||||
icon="fas fa-envelope"
|
||||
v-model="email"
|
||||
@input="emailError = ''"
|
||||
placeholder="邮箱"
|
||||
/>
|
||||
<BaseInput icon="mail" v-model="email" @input="emailError = ''" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
|
||||
<BaseInput
|
||||
icon="fas fa-user"
|
||||
icon="user-icon"
|
||||
v-model="username"
|
||||
@input="usernameError = ''"
|
||||
placeholder="用户名"
|
||||
@@ -23,7 +18,7 @@
|
||||
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
||||
|
||||
<BaseInput
|
||||
icon="fas fa-lock"
|
||||
icon="lock"
|
||||
v-model="password"
|
||||
@input="passwordError = ''"
|
||||
type="password"
|
||||
@@ -40,7 +35,7 @@
|
||||
</div>
|
||||
<div v-else class="signup-page-button-primary disabled">
|
||||
<div class="signup-page-button-text">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<loading-four />
|
||||
发送中...
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="emailStep === 1" class="email-signup-page-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="code" placeholder="邮箱验证码" />
|
||||
<BaseInput icon="mail" v-model="code" placeholder="邮箱验证码" />
|
||||
<div
|
||||
v-if="!isWaitingForEmailVerified"
|
||||
class="signup-page-button-primary"
|
||||
@@ -61,7 +56,7 @@
|
||||
</div>
|
||||
<div v-else class="signup-page-button-primary disabled">
|
||||
<div class="signup-page-button-text">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<loading-four />
|
||||
验证中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
class="profile-page-header-subscribe-button"
|
||||
@click="subscribeUser"
|
||||
>
|
||||
<i class="fas fa-user-plus"></i>
|
||||
<add-user />
|
||||
关注
|
||||
</div>
|
||||
<div
|
||||
@@ -26,11 +26,11 @@
|
||||
class="profile-page-header-unsubscribe-button"
|
||||
@click="unsubscribeUser"
|
||||
>
|
||||
<i class="fas fa-user-minus"></i>
|
||||
<reduce-user />
|
||||
取消关注
|
||||
</div>
|
||||
<div v-if="!isMine" class="profile-page-header-subscribe-button" @click="sendMessage">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<message-one />
|
||||
发私信
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||
placement="bottom"
|
||||
>
|
||||
<i class="fas fa-info-circle profile-exp-info"></i>
|
||||
<info-icon class="profile-exp-info" />
|
||||
</ToolTip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +207,7 @@
|
||||
<BasePlaceholder
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
icon="fas fa-inbox"
|
||||
icon="inbox"
|
||||
/>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
@@ -305,7 +305,7 @@
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<BasePlaceholder text="暂无收藏文章" icon="fas fa-inbox" />
|
||||
<BasePlaceholder text="暂无收藏文章" icon="inbox" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,11 +368,11 @@ const selectedTab = ref(
|
||||
: 'summary',
|
||||
)
|
||||
const tabs = [
|
||||
{ key: 'summary', label: '总结', icon: 'fas fa-chart-line' },
|
||||
{ key: 'timeline', label: '时间线', icon: 'fas fa-clock' },
|
||||
{ key: 'following', label: '关注', icon: 'fas fa-user-plus' },
|
||||
{ key: 'favorites', label: '收藏', icon: 'fas fa-bookmark' },
|
||||
{ key: 'achievements', label: '勋章', icon: 'fas fa-medal' },
|
||||
{ key: 'summary', label: '总结', icon: 'chart-line' },
|
||||
{ key: 'timeline', label: '时间线', icon: 'alarm-clock' },
|
||||
{ key: 'following', label: '关注', icon: 'add-user' },
|
||||
{ key: 'favorites', label: '收藏', icon: 'bookmark' },
|
||||
{ key: 'achievements', label: '勋章', icon: 'medal-one' },
|
||||
]
|
||||
const followTab = ref('followers')
|
||||
|
||||
@@ -415,19 +415,19 @@ 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 }))
|
||||
hotPosts.value = data.map((p) => ({ icon: 'file-text', 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 }))
|
||||
hotReplies.value = data.map((c) => ({ icon: 'comment-icon', 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 }))
|
||||
hotTags.value = data.map((t) => ({ icon: 'tag-one', tag: t }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,19 +443,19 @@ const fetchTimeline = async () => {
|
||||
const mapped = [
|
||||
...posts.map((p) => ({
|
||||
type: 'post',
|
||||
icon: 'fas fa-book',
|
||||
icon: 'file-text',
|
||||
post: p,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
...replies.map((r) => ({
|
||||
type: r.parentComment ? 'reply' : 'comment',
|
||||
icon: 'fas fa-comment',
|
||||
icon: 'comment-icon',
|
||||
comment: r,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
...tags.map((t) => ({
|
||||
type: 'tag',
|
||||
icon: 'fas fa-tag',
|
||||
icon: 'tag-one',
|
||||
tag: t,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
@@ -477,7 +477,7 @@ const fetchFavorites = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}/subscribed-posts`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
favoritePosts.value = data.map((p) => ({ icon: 'fas fa-bookmark', post: p }))
|
||||
favoritePosts.value = data.map((p) => ({ icon: 'bookmark', post: p }))
|
||||
} else {
|
||||
favoritePosts.value = []
|
||||
}
|
||||
|
||||
130
frontend_nuxt/plugins/iconpark.client.ts
Normal file
130
frontend_nuxt/plugins/iconpark.client.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
import {
|
||||
Pin,
|
||||
Fireworks,
|
||||
Gift,
|
||||
RankingList,
|
||||
Star,
|
||||
Edit,
|
||||
HashtagKey,
|
||||
Remind,
|
||||
Info,
|
||||
ChartLine,
|
||||
Finance,
|
||||
Up,
|
||||
Down,
|
||||
TagOne,
|
||||
MedalOne,
|
||||
Next,
|
||||
DropDownList,
|
||||
MoreOne,
|
||||
SunOne,
|
||||
Moon,
|
||||
ComputerOne,
|
||||
Comment,
|
||||
Link,
|
||||
SlyFaceWhitSmile,
|
||||
Like,
|
||||
ApplicationMenu,
|
||||
Search,
|
||||
Copy,
|
||||
Loading,
|
||||
Rss,
|
||||
MessageEmoji,
|
||||
AddUser,
|
||||
ReduceUser,
|
||||
MessageOne,
|
||||
AlarmClock,
|
||||
Bookmark,
|
||||
Inbox,
|
||||
LoadingFour,
|
||||
Mail,
|
||||
Lock,
|
||||
User,
|
||||
Send,
|
||||
Unlock,
|
||||
LockOne,
|
||||
ImageFiles,
|
||||
ExpandUp,
|
||||
Close,
|
||||
ArrowLeft,
|
||||
CollapseTextInput,
|
||||
Stopwatch,
|
||||
PaperMoneyTwo,
|
||||
Check,
|
||||
ChartHistogram,
|
||||
CheckCorrect,
|
||||
PeoplePlus,
|
||||
PeopleMinusOne,
|
||||
SmartOptimization,
|
||||
Save,
|
||||
Clear,
|
||||
FileText,
|
||||
History,
|
||||
Lightning,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('Pin', Pin)
|
||||
nuxtApp.vueApp.component('Fireworks', Fireworks)
|
||||
nuxtApp.vueApp.component('Gift', Gift)
|
||||
nuxtApp.vueApp.component('RankingList', RankingList)
|
||||
nuxtApp.vueApp.component('Star', Star)
|
||||
nuxtApp.vueApp.component('Edit', Edit)
|
||||
nuxtApp.vueApp.component('HashtagKey', HashtagKey)
|
||||
nuxtApp.vueApp.component('Remind', Remind)
|
||||
nuxtApp.vueApp.component('InfoIcon', Info)
|
||||
nuxtApp.vueApp.component('ChartLine', ChartLine)
|
||||
nuxtApp.vueApp.component('Finance', Finance)
|
||||
nuxtApp.vueApp.component('Up', Up)
|
||||
nuxtApp.vueApp.component('Down', Down)
|
||||
nuxtApp.vueApp.component('TagOne', TagOne)
|
||||
nuxtApp.vueApp.component('MedalOne', MedalOne)
|
||||
nuxtApp.vueApp.component('Next', Next)
|
||||
nuxtApp.vueApp.component('DropDownList', DropDownList)
|
||||
nuxtApp.vueApp.component('MoreOne', MoreOne)
|
||||
nuxtApp.vueApp.component('SunOne', SunOne)
|
||||
nuxtApp.vueApp.component('Moon', Moon)
|
||||
nuxtApp.vueApp.component('ComputerOne', ComputerOne)
|
||||
nuxtApp.vueApp.component('CommentIcon', Comment)
|
||||
nuxtApp.vueApp.component('LinkIcon', Link)
|
||||
nuxtApp.vueApp.component('SlyFaceWhitSmile', SlyFaceWhitSmile)
|
||||
nuxtApp.vueApp.component('Like', Like)
|
||||
nuxtApp.vueApp.component('ApplicationMenu', ApplicationMenu)
|
||||
nuxtApp.vueApp.component('SearchIcon', Search)
|
||||
nuxtApp.vueApp.component('Copy', Copy)
|
||||
nuxtApp.vueApp.component('Loading', Loading)
|
||||
nuxtApp.vueApp.component('Rss', Rss)
|
||||
nuxtApp.vueApp.component('MessageEmoji', MessageEmoji)
|
||||
nuxtApp.vueApp.component('AddUser', AddUser)
|
||||
nuxtApp.vueApp.component('ReduceUser', ReduceUser)
|
||||
nuxtApp.vueApp.component('MessageOne', MessageOne)
|
||||
nuxtApp.vueApp.component('AlarmClock', AlarmClock)
|
||||
nuxtApp.vueApp.component('Bookmark', Bookmark)
|
||||
nuxtApp.vueApp.component('Inbox', Inbox)
|
||||
nuxtApp.vueApp.component('LoadingFour', LoadingFour)
|
||||
nuxtApp.vueApp.component('UserIcon', User)
|
||||
nuxtApp.vueApp.component('Mail', Mail)
|
||||
nuxtApp.vueApp.component('Lock', Lock)
|
||||
nuxtApp.vueApp.component('SendIcon', Send)
|
||||
nuxtApp.vueApp.component('Unlock', Unlock)
|
||||
nuxtApp.vueApp.component('LockOne', LockOne)
|
||||
nuxtApp.vueApp.component('ImageFiles', ImageFiles)
|
||||
nuxtApp.vueApp.component('ExpandUp', ExpandUp)
|
||||
nuxtApp.vueApp.component('CloseIcon', Close)
|
||||
nuxtApp.vueApp.component('ArrowLeft', ArrowLeft)
|
||||
nuxtApp.vueApp.component('CollapseTextInput', CollapseTextInput)
|
||||
nuxtApp.vueApp.component('Stopwatch', Stopwatch)
|
||||
nuxtApp.vueApp.component('PaperMoneyTwo', PaperMoneyTwo)
|
||||
nuxtApp.vueApp.component('Check', Check)
|
||||
nuxtApp.vueApp.component('ChartHistogram', ChartHistogram)
|
||||
nuxtApp.vueApp.component('CheckCorrect', CheckCorrect)
|
||||
nuxtApp.vueApp.component('PeoplePlus', PeoplePlus)
|
||||
nuxtApp.vueApp.component('PeopleMinusOne', PeopleMinusOne)
|
||||
nuxtApp.vueApp.component('SmartOptimization', SmartOptimization)
|
||||
nuxtApp.vueApp.component('SaveIcon', Save)
|
||||
nuxtApp.vueApp.component('ClearIcon', Clear)
|
||||
nuxtApp.vueApp.component('FileText', FileText)
|
||||
nuxtApp.vueApp.component('HistoryIcon', History)
|
||||
nuxtApp.vueApp.component('Lightning', Lightning)
|
||||
})
|
||||
@@ -9,28 +9,28 @@ export const notificationState = reactive({
|
||||
})
|
||||
|
||||
const iconMap = {
|
||||
POST_VIEWED: 'fas fa-eye',
|
||||
COMMENT_REPLY: 'fas fa-reply',
|
||||
POST_REVIEWED: 'fas fa-shield-alt',
|
||||
POST_REVIEW_REQUEST: 'fas fa-gavel',
|
||||
POST_UPDATED: 'fas fa-comment-dots',
|
||||
USER_ACTIVITY: 'fas fa-user',
|
||||
FOLLOWED_POST: 'fas fa-feather-alt',
|
||||
USER_FOLLOWED: 'fas fa-user-plus',
|
||||
USER_UNFOLLOWED: 'fas fa-user-minus',
|
||||
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
POINT_REDEEM: 'fas fa-gift',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
POLL_VOTE: 'fas fa-square-poll-vertical',
|
||||
POLL_RESULT_OWNER: 'fas fa-flag-checkered',
|
||||
POLL_RESULT_PARTICIPANT: 'fas fa-flag-checkered',
|
||||
MENTION: 'fas fa-at',
|
||||
POST_DELETED: 'fas fa-trash',
|
||||
POST_FEATURED: 'fas fa-star',
|
||||
POST_VIEWED: 'HistoryIcon',
|
||||
COMMENT_REPLY: 'MessageOne',
|
||||
POST_REVIEWED: 'CheckCorrect',
|
||||
POST_REVIEW_REQUEST: 'FileText',
|
||||
POST_UPDATED: 'Edit',
|
||||
USER_ACTIVITY: 'UserIcon',
|
||||
FOLLOWED_POST: 'Pin',
|
||||
USER_FOLLOWED: 'AddUser',
|
||||
USER_UNFOLLOWED: 'ReduceUser',
|
||||
POST_SUBSCRIBED: 'Bookmark',
|
||||
POST_UNSUBSCRIBED: 'Bookmark',
|
||||
REGISTER_REQUEST: 'AlarmClock',
|
||||
ACTIVITY_REDEEM: 'PaperMoneyTwo',
|
||||
POINT_REDEEM: 'Gift',
|
||||
LOTTERY_WIN: 'MedalOne',
|
||||
LOTTERY_DRAW: 'Fireworks',
|
||||
POLL_VOTE: 'ChartHistogram',
|
||||
POLL_RESULT_OWNER: 'RankingList',
|
||||
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
||||
MENTION: 'HashtagKey',
|
||||
POST_DELETED: 'ClearIcon',
|
||||
POST_FEATURED: 'Star',
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
|
||||
@@ -23,7 +23,7 @@ export default (apiBaseUrl, websiteBaseUrl) => {
|
||||
return (
|
||||
body.map((item) => ({
|
||||
value: `[🔗${item.title}](${websiteBaseUrl}/posts/${item.id})`,
|
||||
html: `<div><i class="fas fa-link"></i> ${item.title}</div>`,
|
||||
html: `<div><link-icon /> ${item.title}</div>`,
|
||||
})) ?? []
|
||||
)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user