mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-24 07:00:49 +08:00
Compare commits
20 Commits
feature/md
...
codex/migr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d8f37f96 | ||
|
|
4a4c256568 | ||
|
|
3bb14ca6a3 | ||
|
|
4ed679c4f4 | ||
|
|
50848e0da1 | ||
|
|
51819913a0 | ||
|
|
741bd115d5 | ||
|
|
d13ee2257f | ||
|
|
06dea47bec | ||
|
|
f89a17f14d | ||
|
|
ac433d6a45 | ||
|
|
62e7795e11 | ||
|
|
722d784691 | ||
|
|
5dab838482 | ||
|
|
67636475aa | ||
|
|
92ae8ae155 | ||
|
|
c0afe9e2a9 | ||
|
|
2c1bef4551 | ||
|
|
202c0f7b59 | ||
|
|
fdd6587fff |
@@ -30,6 +30,19 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
<artifactId>slf4j-api</artifactId>
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ public class Tag {
|
|||||||
@Column(nullable = false, updatable = false,
|
@Column(nullable = false, updatable = false,
|
||||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
// 改用redis缓存之后选择立即加载策略
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.EAGER)
|
||||||
@JoinColumn(name = "creator_id")
|
@JoinColumn(name = "creator_id")
|
||||||
private User creator;
|
private User creator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.config.CachingConfig;
|
||||||
import com.openisle.model.Category;
|
import com.openisle.model.Category;
|
||||||
import com.openisle.repository.CategoryRepository;
|
import com.openisle.repository.CategoryRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -11,7 +14,7 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CategoryService {
|
public class CategoryService {
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
|
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||||
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
||||||
Category category = new Category();
|
Category category = new Category();
|
||||||
category.setName(name);
|
category.setName(name);
|
||||||
@@ -20,7 +23,7 @@ public class CategoryService {
|
|||||||
category.setSmallIcon(smallIcon);
|
category.setSmallIcon(smallIcon);
|
||||||
return categoryRepository.save(category);
|
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) {
|
public Category updateCategory(Long id, String name, String description, String icon, String smallIcon) {
|
||||||
Category category = categoryRepository.findById(id)
|
Category category = categoryRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||||
@@ -38,7 +41,7 @@ public class CategoryService {
|
|||||||
}
|
}
|
||||||
return categoryRepository.save(category);
|
return categoryRepository.save(category);
|
||||||
}
|
}
|
||||||
|
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||||
public void deleteCategory(Long id) {
|
public void deleteCategory(Long id) {
|
||||||
categoryRepository.deleteById(id);
|
categoryRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
@@ -48,6 +51,14 @@ public class CategoryService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该方法每次首页加载都会访问,加入缓存
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
@Cacheable(
|
||||||
|
value = CachingConfig.CATEGORY_CACHE_NAME,
|
||||||
|
key = "'listCategories:'"
|
||||||
|
)
|
||||||
public List<Category> listCategories() {
|
public List<Category> listCategories() {
|
||||||
return categoryRepository.findAll();
|
return categoryRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.config.CachingConfig;
|
||||||
import com.openisle.model.Tag;
|
import com.openisle.model.Tag;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.repository.TagRepository;
|
import com.openisle.repository.TagRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
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.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -18,6 +21,7 @@ public class TagService {
|
|||||||
private final TagValidator tagValidator;
|
private final TagValidator tagValidator;
|
||||||
private final UserRepository userRepository;
|
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) {
|
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved, String creatorUsername) {
|
||||||
tagValidator.validate(name);
|
tagValidator.validate(name);
|
||||||
Tag tag = new Tag();
|
Tag tag = new Tag();
|
||||||
@@ -42,6 +46,7 @@ public class TagService {
|
|||||||
return createTag(name, description, icon, smallIcon, true, null);
|
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) {
|
public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) {
|
||||||
Tag tag = tagRepository.findById(id)
|
Tag tag = tagRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
|
||||||
@@ -61,6 +66,7 @@ public class TagService {
|
|||||||
return tagRepository.save(tag);
|
return tagRepository.save(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||||
public void deleteTag(Long id) {
|
public void deleteTag(Long id) {
|
||||||
tagRepository.deleteById(id);
|
tagRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
@@ -85,10 +91,20 @@ public class TagService {
|
|||||||
return tagRepository.findByApprovedTrue();
|
return tagRepository.findByApprovedTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该方法每次首页加载都会访问,加入缓存
|
||||||
|
* @param keyword
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
@Cacheable(
|
||||||
|
value = CachingConfig.TAG_CACHE_NAME,
|
||||||
|
key = "'searchTags:' + (#keyword ?: '')"//keyword为null的场合返回空
|
||||||
|
)
|
||||||
public List<Tag> searchTags(String keyword) {
|
public List<Tag> searchTags(String keyword) {
|
||||||
if (keyword == null || keyword.isBlank()) {
|
if (keyword == null || keyword.isBlank()) {
|
||||||
return tagRepository.findByApprovedTrue();
|
return tagRepository.findByApprovedTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ spring.datasource.username=${MYSQL_USER:root}
|
|||||||
spring.datasource.password=${MYSQL_PASSWORD:password}
|
spring.datasource.password=${MYSQL_PASSWORD:password}
|
||||||
spring.jpa.hibernate.ddl-auto=update
|
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
|
# for jwt
|
||||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ CREATE TABLE IF NOT EXISTS `users` (
|
|||||||
-- 清空users表
|
-- 清空users表
|
||||||
DELETE FROM `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
|
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'),
|
(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', '普通用户2', 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', '普通用户1', 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 表(如果不存在)
|
-- 创建 tags 表(如果不存在)
|
||||||
CREATE TABLE IF NOT EXISTS `tags` (
|
CREATE TABLE IF NOT EXISTS `tags` (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
class="app-new-post-icon"
|
class="app-new-post-icon"
|
||||||
@click="goToNewPost"
|
@click="goToNewPost"
|
||||||
>
|
>
|
||||||
<i class="fas fa-edit"></i>
|
<edit />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GlobalPopups />
|
<GlobalPopups />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="base-input">
|
<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
|
<input
|
||||||
@@ -29,7 +29,7 @@ export default {
|
|||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
props: {
|
props: {
|
||||||
modelValue: { type: [String, Number], default: '' },
|
modelValue: { type: [String, Number], default: '' },
|
||||||
icon: { type: String, default: '' },
|
icon: { type: [String, Object], default: '' },
|
||||||
type: { type: String, default: 'text' },
|
type: { type: String, default: 'text' },
|
||||||
textarea: { type: Boolean, default: false },
|
textarea: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,6 @@ export default {
|
|||||||
|
|
||||||
.base-input-icon {
|
.base-input-icon {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-input-text {
|
.base-input-text {
|
||||||
|
|||||||
@@ -8,7 +8,12 @@
|
|||||||
:class="['base-tabs-item', { selected: modelValue === tab.key }]"
|
:class="['base-tabs-item', { selected: modelValue === tab.key }]"
|
||||||
@click="$emit('update: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"
|
||||||
|
/>
|
||||||
|
<i v-else-if="tab.icon" :class="tab.icon"></i>
|
||||||
<div class="base-tabs-item-label">{{ tab.label }}</div>
|
<div class="base-tabs-item-label">{{ tab.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +77,7 @@ function onTouchEnd(e) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.base-tabs-item-icon,
|
||||||
.base-tabs-item i {
|
.base-tabs-item i {
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,19 +15,23 @@
|
|||||||
<div class="common-info-content-header">
|
<div class="common-info-content-header">
|
||||||
<div class="info-content-header-left">
|
<div class="info-content-header-left">
|
||||||
<span class="user-name">{{ comment.userName }}</span>
|
<span class="user-name">{{ comment.userName }}</span>
|
||||||
<i class="fas fa-medal medal-icon"></i>
|
<medal-one class="medal-icon" />
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="comment.medal"
|
v-if="comment.medal"
|
||||||
class="medal-name"
|
class="medal-name"
|
||||||
:to="`/users/${comment.userId}?tab=achievements`"
|
:to="`/users/${comment.userId}?tab=achievements`"
|
||||||
>{{ getMedalTitle(comment.medal) }}</NuxtLink
|
>{{ 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">
|
<span v-if="level >= 2" class="reply-item">
|
||||||
<i class="fas fa-reply reply-icon"></i>
|
<next class="reply-icon" />
|
||||||
<span class="reply-info">
|
<span class="reply-info">
|
||||||
<BaseImage class="reply-avatar" :src="comment.parentUserAvatar || '/default-avatar.svg'" alt="avatar"
|
<BaseImage
|
||||||
@click="comment.parentUserClick && comment.parentUserClick()" />
|
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 class="reply-user-name">{{ comment.parentUserName }}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -36,7 +40,7 @@
|
|||||||
<div class="info-content-header-right">
|
<div class="info-content-header-right">
|
||||||
<DropdownMenu v-if="commentMenuItems.length > 0" :items="commentMenuItems">
|
<DropdownMenu v-if="commentMenuItems.length > 0" :items="commentMenuItems">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<i class="fas fa-ellipsis-vertical action-menu-icon"></i>
|
<more-one class="action-menu-icon" />
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,10 +53,10 @@
|
|||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
||||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
||||||
<i class="far fa-comment"></i>
|
<comment-icon />
|
||||||
</div>
|
</div>
|
||||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
||||||
<i class="fas fa-link"></i>
|
<link-icon />
|
||||||
</div>
|
</div>
|
||||||
</ReactionsGroup>
|
</ReactionsGroup>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,7 +385,8 @@ const handleContentClick = (e) => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-item, .reply-info {
|
.reply-item,
|
||||||
|
.reply-info {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -397,13 +402,16 @@ const handleContentClick = (e) => {
|
|||||||
|
|
||||||
.reply-icon {
|
.reply-icon {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
margin-right: 10px;
|
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-user-name {
|
.reply-user-name {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
|
display: none;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medal-name {
|
.medal-name {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="header-content-left">
|
<div class="header-content-left">
|
||||||
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
||||||
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
|
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
|
||||||
<i class="fas fa-bars micon"></i>
|
<application-menu class="micon"></application-menu>
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
|
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
|
||||||
@@ -25,34 +25,34 @@
|
|||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div 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>
|
<search-icon />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
||||||
<i :class="iconClass"></i>
|
<component :is="iconClass" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
|
<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>
|
</div>
|
||||||
|
|
||||||
<ToolTip content="复制RSS链接" placement="bottom">
|
<ToolTip content="复制RSS链接" placement="bottom">
|
||||||
<div class="rss-icon" @click="copyRssLink">
|
<div class="rss-icon" @click="copyRssLink">
|
||||||
<i class="fas fa-rss"></i>
|
<rss />
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
|
||||||
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||||
<div class="new-post-icon" @click="goToNewPost">
|
<div class="new-post-icon" @click="goToNewPost">
|
||||||
<i class="fas fa-edit"></i>
|
<edit />
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
|
||||||
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
||||||
<div class="messages-icon" @click="goToMessages">
|
<div class="messages-icon" @click="goToMessages">
|
||||||
<i class="fas fa-comments"></i>
|
<message-emoji />
|
||||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
||||||
unreadMessageCount
|
unreadMessageCount
|
||||||
}}</span>
|
}}</span>
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<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" />
|
||||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
<down />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -226,11 +226,11 @@ const headerMenuItems = computed(() => [
|
|||||||
const iconClass = computed(() => {
|
const iconClass = computed(() => {
|
||||||
switch (themeState.mode) {
|
switch (themeState.mode) {
|
||||||
case ThemeMode.DARK:
|
case ThemeMode.DARK:
|
||||||
return 'fas fa-moon'
|
return 'Moon'
|
||||||
case ThemeMode.LIGHT:
|
case ThemeMode.LIGHT:
|
||||||
return 'fas fa-sun'
|
return 'SunOne'
|
||||||
default:
|
default:
|
||||||
return 'fas fa-desktop'
|
return 'ComputerOne'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="menu-content">
|
<div class="menu-content">
|
||||||
<div class="menu-item-container">
|
<div class="menu-item-container">
|
||||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
<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>
|
<span class="menu-item-text">话题</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
to="/new-post"
|
to="/new-post"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
>
|
>
|
||||||
<i class="menu-item-icon fas fa-edit"></i>
|
<edit class="menu-item-icon" />
|
||||||
<span class="menu-item-text">发帖</span>
|
<span class="menu-item-text">发帖</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
to="/message"
|
to="/message"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
>
|
>
|
||||||
<i class="menu-item-icon fas fa-envelope"></i>
|
<remind class="menu-item-icon" />
|
||||||
<span class="menu-item-text">我的消息</span>
|
<span class="menu-item-text">我的消息</span>
|
||||||
<span v-if="unreadCount > 0" class="unread-container">
|
<span v-if="unreadCount > 0" class="unread-container">
|
||||||
<span class="unread"> {{ showUnreadCount }} </span>
|
<span class="unread"> {{ showUnreadCount }} </span>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
to="/about"
|
to="/about"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
>
|
>
|
||||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
<info class="menu-item-icon" />
|
||||||
<span class="menu-item-text">关于</span>
|
<span class="menu-item-text">关于</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
to="/activities"
|
to="/activities"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
>
|
>
|
||||||
<i class="menu-item-icon fas fa-gift"></i>
|
<gift class="menu-item-icon" />
|
||||||
<span class="menu-item-text">🔥 活动</span>
|
<span class="menu-item-text">🔥 活动</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
to="/about/stats"
|
to="/about/stats"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
>
|
>
|
||||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
<chart-line class="menu-item-icon" />
|
||||||
<span class="menu-item-text">站点统计</span>
|
<span class="menu-item-text">站点统计</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
to="/points"
|
to="/points"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
>
|
>
|
||||||
<i class="menu-item-icon fas fa-coins"></i>
|
<finance class="menu-item-icon" />
|
||||||
<span class="menu-item-text">
|
<span class="menu-item-text">
|
||||||
积分商城
|
积分商城
|
||||||
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||||
@@ -74,7 +74,8 @@
|
|||||||
<div class="menu-section">
|
<div class="menu-section">
|
||||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||||
<span>类别</span>
|
<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>
|
||||||
<div v-if="categoryOpen" class="section-items">
|
<div v-if="categoryOpen" class="section-items">
|
||||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
class="section-item-icon"
|
class="section-item-icon"
|
||||||
:alt="c.name"
|
: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>
|
</template>
|
||||||
<span class="section-item-text">
|
<span class="section-item-text">
|
||||||
{{ c.name }}
|
{{ c.name }}
|
||||||
@@ -107,7 +108,8 @@
|
|||||||
<div class="menu-section">
|
<div class="menu-section">
|
||||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||||
<span>标签</span>
|
<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>
|
||||||
<div v-if="tagOpen" class="section-items">
|
<div v-if="tagOpen" class="section-items">
|
||||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||||
@@ -120,7 +122,7 @@
|
|||||||
class="section-item-icon"
|
class="section-item-icon"
|
||||||
:alt="t.name"
|
: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"
|
<span class="section-item-text"
|
||||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||||
>
|
>
|
||||||
@@ -133,7 +135,7 @@
|
|||||||
<ClientOnly v-if="!isMobile">
|
<ClientOnly v-if="!isMobile">
|
||||||
<div class="menu-footer">
|
<div class="menu-footer">
|
||||||
<div class="menu-footer-btn" @click="cycleTheme">
|
<div class="menu-footer-btn" @click="cycleTheme">
|
||||||
<i :class="iconClass"></i>
|
<component :is="iconClass" class="menu-item-icon" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
@@ -193,11 +195,11 @@ const {
|
|||||||
const iconClass = computed(() => {
|
const iconClass = computed(() => {
|
||||||
switch (themeState.mode) {
|
switch (themeState.mode) {
|
||||||
case ThemeMode.DARK:
|
case ThemeMode.DARK:
|
||||||
return 'fas fa-moon'
|
return 'Moon'
|
||||||
case ThemeMode.LIGHT:
|
case ThemeMode.LIGHT:
|
||||||
return 'fas fa-sun'
|
return 'SunOne'
|
||||||
default:
|
default:
|
||||||
return 'fas fa-desktop'
|
return 'ComputerOne'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
<div class="prize-count">x {{ lottery.prizeCount }}</div>
|
<div class="prize-count">x {{ lottery.prizeCount }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prize-end-time prize-info-right">
|
<div class="prize-end-time prize-info-right">
|
||||||
<i class="fas fa-stopwatch prize-end-time-icon"></i>
|
<i v-if="!lotteryEnded" class="fas fa-stopwatch prize-end-time-icon"></i>
|
||||||
<div v-if="!isMobile" class="prize-end-time-title">离结束</div>
|
<div v-if="!isMobile && !lotteryEnded" class="prize-end-time-title">离结束</div>
|
||||||
<div class="prize-end-time-value">{{ countdown }}</div>
|
<div class="prize-end-time-value">{{ countdown }}</div>
|
||||||
<div v-if="!isMobile" class="join-prize-button-container-desktop">
|
<div v-if="!isMobile" class="join-prize-button-container-desktop">
|
||||||
<div
|
<div
|
||||||
@@ -193,6 +193,7 @@ const joinLottery = async () => {
|
|||||||
|
|
||||||
.prize-end-time-icon {
|
.prize-end-time-icon {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prize-end-time-title {
|
.prize-end-time-title {
|
||||||
|
|||||||
@@ -19,8 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||||
<i class="far fa-smile reactions-viewer-item-placeholder-icon"></i>
|
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||||
<!-- <span class="reactions-viewer-item-placeholder-text">点击以表态</span> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="displayedReactions.length">
|
<template v-else-if="displayedReactions.length">
|
||||||
@@ -42,7 +41,7 @@
|
|||||||
class="make-reaction-item like-reaction"
|
class="make-reaction-item like-reaction"
|
||||||
@click="toggleReaction('LIKE')"
|
@click="toggleReaction('LIKE')"
|
||||||
>
|
>
|
||||||
<i v-if="!userReacted('LIKE')" class="far fa-heart"></i>
|
<like v-if="!userReacted('LIKE')" />
|
||||||
<i v-else class="fas fa-heart"></i>
|
<i v-else class="fas fa-heart"></i>
|
||||||
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ export default defineNuxtConfig({
|
|||||||
telegramBotId: process.env.NUXT_PUBLIC_TELEGRAM_BOT_ID || '',
|
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: {
|
app: {
|
||||||
pageTransition: { name: 'page', mode: 'out-in' },
|
pageTransition: { name: 'page', mode: 'out-in' },
|
||||||
head: {
|
head: {
|
||||||
@@ -72,11 +77,11 @@ export default defineNuxtConfig({
|
|||||||
rel: 'manifest',
|
rel: 'manifest',
|
||||||
href: '/manifest.webmanifest',
|
href: '/manifest.webmanifest',
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
rel: 'stylesheet',
|
// rel: 'stylesheet',
|
||||||
href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
// href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
||||||
referrerpolicy: 'no-referrer',
|
// referrerpolicy: 'no-referrer',
|
||||||
},
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
baseURL: '/',
|
baseURL: '/',
|
||||||
|
|||||||
17
frontend_nuxt/package-lock.json
generated
17
frontend_nuxt/package-lock.json
generated
@@ -6,6 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "frontend_nuxt",
|
"name": "frontend_nuxt",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@icon-park/vue-next": "^1.4.2",
|
||||||
"@nuxt/image": "^1.11.0",
|
"@nuxt/image": "^1.11.0",
|
||||||
"@stomp/stompjs": "^7.0.0",
|
"@stomp/stompjs": "^7.0.0",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
@@ -25,6 +26,9 @@
|
|||||||
"vue-echarts": "^7.0.3",
|
"vue-echarts": "^7.0.3",
|
||||||
"vue-flatpickr-component": "^12.0.0",
|
"vue-flatpickr-component": "^12.0.0",
|
||||||
"vue-toastification": "^2.0.0-rc.5"
|
"vue-toastification": "^2.0.0-rc.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@@ -990,6 +994,19 @@
|
|||||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.34.3",
|
"version": "0.34.3",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"generate": "nuxt generate"
|
"generate": "nuxt generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@icon-park/vue-next": "^1.4.2",
|
||||||
"@nuxt/image": "^1.11.0",
|
"@nuxt/image": "^1.11.0",
|
||||||
"@stomp/stompjs": "^7.0.0",
|
"@stomp/stompjs": "^7.0.0",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
<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="Mail" v-model="email" placeholder="邮箱" />
|
||||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||||
<div class="primary-button" @click="sendCode" v-if="!isSending">发送验证码</div>
|
<div class="primary-button" @click="sendCode" v-if="!isSending">发送验证码</div>
|
||||||
<div class="primary-button disabled" v-else>发送中...</div>
|
<div class="primary-button disabled" v-else>发送中...</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="step === 1" class="step-content">
|
<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" @click="verifyCode" v-if="!isVerifying">验证</div>
|
||||||
<div class="primary-button disabled" v-else>验证中...</div>
|
<div class="primary-button disabled" v-else>验证中...</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="step-content">
|
<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 v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||||
<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>
|
||||||
|
|||||||
@@ -68,13 +68,10 @@
|
|||||||
>
|
>
|
||||||
<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>
|
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
||||||
<i v-if="article.type === 'LOTTERY'" class="fa-solid fa-gift lottery-icon"></i>
|
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
||||||
<i
|
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
||||||
v-else-if="article.type === 'POLL'"
|
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||||
class="fa-solid fa-square-poll-vertical poll-icon"
|
|
||||||
></i>
|
|
||||||
<i v-if="!article.rssExcluded" class="fa-solid fa-star featured-icon"></i>
|
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||||
@@ -141,7 +138,6 @@ import { getToken } from '~/utils/auth'
|
|||||||
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'
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'OpenIsle - 全面开源的自由社区',
|
title: 'OpenIsle - 全面开源的自由社区',
|
||||||
meta: [
|
meta: [
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="email-login-page-content">
|
<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 v-if="!isWaitingForLogin" class="login-page-button-primary" @click="submitLogin">
|
||||||
<div class="login-page-button-text">登录</div>
|
<div class="login-page-button-text">登录</div>
|
||||||
|
|||||||
@@ -786,7 +786,6 @@ onMounted(async () => {
|
|||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row username-row">
|
<div class="form-row username-row">
|
||||||
<BaseInput
|
<BaseInput
|
||||||
icon="fas fa-user"
|
icon="User"
|
||||||
v-model="username"
|
v-model="username"
|
||||||
@input="usernameError = ''"
|
@input="usernameError = ''"
|
||||||
placeholder="用户名"
|
placeholder="用户名"
|
||||||
|
|||||||
@@ -6,16 +6,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="emailStep === 0" class="email-signup-page-content">
|
<div v-if="emailStep === 0" class="email-signup-page-content">
|
||||||
<BaseInput
|
<BaseInput icon="Mail" v-model="email" @input="emailError = ''" placeholder="邮箱" />
|
||||||
icon="fas fa-envelope"
|
|
||||||
v-model="email"
|
|
||||||
@input="emailError = ''"
|
|
||||||
placeholder="邮箱"
|
|
||||||
/>
|
|
||||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||||
|
|
||||||
<BaseInput
|
<BaseInput
|
||||||
icon="fas fa-user"
|
icon="User"
|
||||||
v-model="username"
|
v-model="username"
|
||||||
@input="usernameError = ''"
|
@input="usernameError = ''"
|
||||||
placeholder="用户名"
|
placeholder="用户名"
|
||||||
@@ -23,7 +18,7 @@
|
|||||||
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
||||||
|
|
||||||
<BaseInput
|
<BaseInput
|
||||||
icon="fas fa-lock"
|
icon="Lock"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
@input="passwordError = ''"
|
@input="passwordError = ''"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -51,7 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="emailStep === 1" class="email-signup-page-content">
|
<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
|
<div
|
||||||
v-if="!isWaitingForEmailVerified"
|
v-if="!isWaitingForEmailVerified"
|
||||||
class="signup-page-button-primary"
|
class="signup-page-button-primary"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
class="profile-page-header-subscribe-button"
|
class="profile-page-header-subscribe-button"
|
||||||
@click="subscribeUser"
|
@click="subscribeUser"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user-plus"></i>
|
<add-user />
|
||||||
关注
|
关注
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
class="profile-page-header-unsubscribe-button"
|
class="profile-page-header-unsubscribe-button"
|
||||||
@click="unsubscribeUser"
|
@click="unsubscribeUser"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user-minus"></i>
|
<reduce-user />
|
||||||
取消关注
|
取消关注
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isMine" class="profile-page-header-subscribe-button" @click="sendMessage">
|
<div v-if="!isMine" class="profile-page-header-subscribe-button" @click="sendMessage">
|
||||||
<i class="fas fa-paper-plane"></i>
|
<message-one />
|
||||||
发私信
|
发私信
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<i class="fas fa-info-circle profile-exp-info"></i>
|
<info class="profile-exp-info" />
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,11 +368,11 @@ const selectedTab = ref(
|
|||||||
: 'summary',
|
: 'summary',
|
||||||
)
|
)
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'summary', label: '总结', icon: 'fas fa-chart-line' },
|
{ key: 'summary', label: '总结', icon: 'ChartLine' },
|
||||||
{ key: 'timeline', label: '时间线', icon: 'fas fa-clock' },
|
{ key: 'timeline', label: '时间线', icon: 'AlarmClock' },
|
||||||
{ key: 'following', label: '关注', icon: 'fas fa-user-plus' },
|
{ key: 'following', label: '关注', icon: 'AddUser' },
|
||||||
{ key: 'favorites', label: '收藏', icon: 'fas fa-bookmark' },
|
{ key: 'favorites', label: '收藏', icon: 'Bookmark' },
|
||||||
{ key: 'achievements', label: '勋章', icon: 'fas fa-medal' },
|
{ key: 'achievements', label: '勋章', icon: 'MedalOne' },
|
||||||
]
|
]
|
||||||
const followTab = ref('followers')
|
const followTab = ref('followers')
|
||||||
|
|
||||||
|
|||||||
84
frontend_nuxt/plugins/iconpark.client.ts
Normal file
84
frontend_nuxt/plugins/iconpark.client.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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,
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
} 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('Info', 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('Mail', Mail)
|
||||||
|
nuxtApp.vueApp.component('Lock', Lock)
|
||||||
|
nuxtApp.vueApp.component('User', User)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user