update theme and user top
This commit is contained in:
@@ -50,6 +50,14 @@ public class Node {
|
||||
|
||||
private String ipAddress;
|
||||
|
||||
private String ipRegion;
|
||||
|
||||
private String browser;
|
||||
|
||||
private Integer likeCount;
|
||||
|
||||
private Integer dislikeCount;
|
||||
|
||||
private String copyright;
|
||||
|
||||
private Integer isApproved;
|
||||
|
||||
@@ -52,6 +52,33 @@ public class PostCommonUtil {
|
||||
postCustom.setAvatar(avatar);
|
||||
postCustom.setUsername(mapObj.get("create_username") != null ? String.valueOf(mapObj.get("create_username")) : null);
|
||||
postCustom.setRealname(mapObj.get("create_realname") != null ? String.valueOf(mapObj.get("create_realname")) : null);
|
||||
// floor number
|
||||
if (mapObj.get("number") != null) {
|
||||
postCustom.setNumber(Integer.parseInt(String.valueOf(mapObj.get("number"))));
|
||||
}
|
||||
// create user id
|
||||
if (mapObj.get("create_id") != null) {
|
||||
postCustom.setCreateId(Integer.parseInt(String.valueOf(mapObj.get("create_id"))));
|
||||
}
|
||||
// ip address
|
||||
if (mapObj.get("ip_address") != null) {
|
||||
postCustom.setIpAddress(String.valueOf(mapObj.get("ip_address")));
|
||||
}
|
||||
// ip region (may be null if column not yet populated)
|
||||
if (mapObj.get("ip_region") != null) {
|
||||
postCustom.setIpRegion(String.valueOf(mapObj.get("ip_region")));
|
||||
}
|
||||
// browser info (user_agent stored but only display-parsed browser is used)
|
||||
if (mapObj.get("browser") != null) {
|
||||
postCustom.setBrowser(String.valueOf(mapObj.get("browser")));
|
||||
}
|
||||
// vote counters
|
||||
if (mapObj.get("like_count") != null) {
|
||||
postCustom.setLikeCount(Integer.parseInt(String.valueOf(mapObj.get("like_count"))));
|
||||
}
|
||||
if (mapObj.get("dislike_count") != null) {
|
||||
postCustom.setDislikeCount(Integer.parseInt(String.valueOf(mapObj.get("dislike_count"))));
|
||||
}
|
||||
String avatarReply = "";
|
||||
if (mapObj.get("user_avatar") != null) {
|
||||
if (mapObj.get("user_avatar") instanceof Clob) {
|
||||
|
||||
@@ -12,6 +12,9 @@ import com.yaoyuan.jiscuss.service.IDiscussionsTagsService;
|
||||
import com.yaoyuan.jiscuss.service.IPostsService;
|
||||
import com.yaoyuan.jiscuss.service.ITagsService;
|
||||
import com.yaoyuan.jiscuss.service.IUsersService;
|
||||
import com.yaoyuan.jiscuss.service.IpRegionService;
|
||||
import com.yaoyuan.jiscuss.util.IpUtils;
|
||||
import com.yaoyuan.jiscuss.util.UserAgentUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
@@ -56,6 +59,9 @@ public class UserPostController extends BaseController {
|
||||
@Autowired
|
||||
private IUsersService usersService;
|
||||
|
||||
@Autowired
|
||||
private IpRegionService ipRegionService;
|
||||
|
||||
|
||||
/**
|
||||
* 首页查看主题列表(各种条件筛选,最热最新标签等)
|
||||
@@ -70,8 +76,10 @@ public class UserPostController extends BaseController {
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping("/getdiscussionsbyid")
|
||||
public String getDiscussionsById(HttpServletRequest request, ModelMap map, @RequestParam("id") Integer id) {
|
||||
logger.info(">>> getDiscussionsById{}", id);
|
||||
public String getDiscussionsById(HttpServletRequest request, ModelMap map,
|
||||
@RequestParam("id") Integer id,
|
||||
@RequestParam(defaultValue = "newest") String sort) {
|
||||
logger.info(">>> getDiscussionsById{} sort={}", id, sort);
|
||||
|
||||
// Atomically increment view count before loading the discussion
|
||||
discussionsService.incrementViewCount(id);
|
||||
@@ -96,10 +104,11 @@ public class UserPostController extends BaseController {
|
||||
}
|
||||
|
||||
List<Tag> tags = tagsService.findByDId(id);
|
||||
List postsObj = postsService.findPostCustomById(id);
|
||||
List postsObj = postsService.findPostCustomById(id, sort);
|
||||
map.put("tags", tags);
|
||||
map.put("discussions", newdd);
|
||||
map.put("posts", postsObj);
|
||||
map.put("sort", sort);
|
||||
UserInfo user = getUserInfo(request);
|
||||
if (user != null) {
|
||||
map.put("username", user.getUsername());
|
||||
@@ -183,6 +192,13 @@ public class UserPostController extends BaseController {
|
||||
post.setCreateId(user.getId());
|
||||
}
|
||||
post.setCreateTime(new Date());
|
||||
|
||||
// Capture client IP, region, and browser
|
||||
String clientIp = IpUtils.getClientIp(request);
|
||||
post.setIpAddress(clientIp);
|
||||
post.setIpRegion(ipRegionService.getRegion(clientIp));
|
||||
post.setBrowser(UserAgentUtils.parseBrowser(request.getHeader("User-Agent")));
|
||||
|
||||
if (null != post.getParentId()) {
|
||||
Post temp = postsService.findOneByid(post.getParentId());
|
||||
post.setUserId(temp.getCreateId());
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.ModelMap;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -50,6 +51,9 @@ public class UserSystemController extends BaseController {
|
||||
@Autowired
|
||||
private ITagsService tagsService;
|
||||
|
||||
@Autowired
|
||||
private BCryptPasswordEncoder passwordEncoder;
|
||||
|
||||
/**
|
||||
* 首页index
|
||||
* @param tag
|
||||
@@ -113,6 +117,7 @@ public class UserSystemController extends BaseController {
|
||||
map.put("pageNum", pageNum);
|
||||
map.put("pageTotalPages", allDiscussionsPage.getTotalPages());
|
||||
map.put("pageNumAll", allDiscussionsPage.getTotalPages());
|
||||
map.put("topUsers", usersService.getTopActiveUsers(10));
|
||||
UserInfo user = getUserInfo(request);
|
||||
if (user != null) {
|
||||
map.put("username", user.getUsername());
|
||||
@@ -251,7 +256,7 @@ public class UserSystemController extends BaseController {
|
||||
} else {
|
||||
User user = new User();
|
||||
user.setEmail(email);
|
||||
user.setPassword(password);
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
user.setUsername(username);
|
||||
user.setRealname(username);
|
||||
user.setJoinTime(new Date());
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.yaoyuan.jiscuss.controller.api;
|
||||
|
||||
import com.yaoyuan.jiscuss.controller.BaseController;
|
||||
import com.yaoyuan.jiscuss.entity.UserInfo;
|
||||
import com.yaoyuan.jiscuss.response.ApiResponse;
|
||||
import com.yaoyuan.jiscuss.service.ILikeCollectService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST API for voting (like / dislike) on discussions and posts.
|
||||
*/
|
||||
@Tag(name = "Vote API", description = "Like and dislike discussions and posts")
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class VoteController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
private ILikeCollectService likeCollectService;
|
||||
|
||||
@Operation(summary = "Vote on a discussion")
|
||||
@PostMapping("/discussions/{id}/vote")
|
||||
public ApiResponse<Map<String, String>> voteDiscussion(
|
||||
@PathVariable Integer id,
|
||||
@RequestParam String action,
|
||||
HttpServletRequest request) {
|
||||
UserInfo user = getUserInfo(request);
|
||||
if (user == null) {
|
||||
return ApiResponse.fail(401, "请先登录");
|
||||
}
|
||||
if (!"like".equals(action) && !"dislike".equals(action)) {
|
||||
return ApiResponse.fail(400, "action 参数必须是 like 或 dislike");
|
||||
}
|
||||
String result = likeCollectService.voteDiscussion(user.getId(), id, action);
|
||||
return ApiResponse.ok(Map.of("action", result));
|
||||
}
|
||||
|
||||
@Operation(summary = "Vote on a post (reply)")
|
||||
@PostMapping("/posts/{id}/vote")
|
||||
public ApiResponse<Map<String, String>> votePost(
|
||||
@PathVariable Integer id,
|
||||
@RequestParam String action,
|
||||
HttpServletRequest request) {
|
||||
UserInfo user = getUserInfo(request);
|
||||
if (user == null) {
|
||||
return ApiResponse.fail(401, "请先登录");
|
||||
}
|
||||
if (!"like".equals(action) && !"dislike".equals(action)) {
|
||||
return ApiResponse.fail(400, "action 参数必须是 like 或 dislike");
|
||||
}
|
||||
String result = likeCollectService.votePost(user.getId(), id, action);
|
||||
return ApiResponse.ok(Map.of("action", result));
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ public class Discussion implements Serializable {
|
||||
@Column(name = "like_count")
|
||||
private Integer likeCount;
|
||||
|
||||
@Column(name = "dislike_count")
|
||||
private Integer dislikeCount;
|
||||
|
||||
/** IP is resolved server-side via IpUtils.getClientIp() — never trusted from X-Forwarded-For alone. */
|
||||
@Column(name = "ip_address")
|
||||
private String ipAddress;
|
||||
|
||||
@@ -48,6 +48,10 @@ public class LikeCollect implements Serializable {
|
||||
@Column(name = "type")
|
||||
private String type;
|
||||
|
||||
/** Vote action: 'like' or 'dislike'. */
|
||||
@Column(name = "action")
|
||||
private String action;
|
||||
|
||||
@Column(name = "like_type")
|
||||
private String likeType;
|
||||
|
||||
|
||||
@@ -54,12 +54,26 @@ public class Post implements Serializable {
|
||||
@Column(name = "ip_address")
|
||||
private String ipAddress;
|
||||
|
||||
/** Province/region derived from IP (e.g. "北京", "广东"). Stored on post creation. */
|
||||
@Column(name = "ip_region")
|
||||
private String ipRegion;
|
||||
|
||||
/** Parsed browser name+version (e.g. "Chrome 120"). Derived from User-Agent on post creation. */
|
||||
@Column(name = "browser")
|
||||
private String browser;
|
||||
|
||||
@Column(name = "copyright")
|
||||
private String copyright;
|
||||
|
||||
@Column(name = "is_approved")
|
||||
private Integer isApproved;
|
||||
|
||||
@Column(name = "like_count")
|
||||
private Integer likeCount;
|
||||
|
||||
@Column(name = "dislike_count")
|
||||
private Integer dislikeCount;
|
||||
|
||||
@Column(name = "create_id")
|
||||
private Integer createId;
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Repository
|
||||
public interface DiscussionsRepository extends JpaRepository<Discussion, Integer> {
|
||||
|
||||
@@ -20,4 +22,24 @@ public interface DiscussionsRepository extends JpaRepository<Discussion, Integer
|
||||
@Transactional
|
||||
@Query("UPDATE Discussion d SET d.viewCount = d.viewCount + 1 WHERE d.id = :id")
|
||||
void incrementViewCount(@org.springframework.data.repository.query.Param("id") Integer id);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE Discussion d SET d.commentsCount = COALESCE(d.commentsCount, 0) + 1, d.lastTime = :lastTime, d.lastUserId = :userId WHERE d.id = :id")
|
||||
void incrementCommentCount(
|
||||
@org.springframework.data.repository.query.Param("id") Integer id,
|
||||
@org.springframework.data.repository.query.Param("userId") Integer userId,
|
||||
@org.springframework.data.repository.query.Param("lastTime") Date lastTime);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE Discussion d SET d.likeCount = COALESCE(d.likeCount, 0) + :delta WHERE d.id = :id")
|
||||
void adjustLikeCount(@org.springframework.data.repository.query.Param("id") Integer id,
|
||||
@org.springframework.data.repository.query.Param("delta") int delta);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE Discussion d SET d.dislikeCount = COALESCE(d.dislikeCount, 0) + :delta WHERE d.id = :id")
|
||||
void adjustDislikeCount(@org.springframework.data.repository.query.Param("id") Integer id,
|
||||
@org.springframework.data.repository.query.Param("delta") int delta);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yaoyuan.jiscuss.repository;
|
||||
|
||||
import com.yaoyuan.jiscuss.entity.LikeCollect;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface LikeCollectRepository extends JpaRepository<LikeCollect, Integer> {
|
||||
|
||||
/** Find an existing vote record for a user on a discussion. */
|
||||
@Query("FROM LikeCollect lc WHERE lc.userId = :userId AND lc.discussionId = :discussionId AND lc.type = 'discussion'")
|
||||
Optional<LikeCollect> findDiscussionVote(@Param("userId") Integer userId, @Param("discussionId") Integer discussionId);
|
||||
|
||||
/** Find an existing vote record for a user on a post. */
|
||||
@Query("FROM LikeCollect lc WHERE lc.userId = :userId AND lc.postId = :postId AND lc.type = 'post'")
|
||||
Optional<LikeCollect> findPostVote(@Param("userId") Integer userId, @Param("postId") Integer postId);
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package com.yaoyuan.jiscuss.repository;
|
||||
|
||||
import com.yaoyuan.jiscuss.entity.Post;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -21,6 +23,20 @@ public interface PostsRepository extends JpaRepository<Post, Integer> {
|
||||
@Query("from Post where discussionId = :dId")
|
||||
List<Post> findOneBy(@Param("dId") Integer dId);
|
||||
|
||||
/** Returns the current max floor number for a discussion (0 if no posts yet). */
|
||||
@Query("SELECT COALESCE(MAX(p.number), 0) FROM Post p WHERE p.discussionId = :dId")
|
||||
int findMaxNumberByDiscussionId(@Param("dId") Integer dId);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE Post p SET p.likeCount = COALESCE(p.likeCount, 0) + :delta WHERE p.id = :id")
|
||||
void adjustLikeCount(@Param("id") Integer id, @Param("delta") int delta);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE Post p SET p.dislikeCount = COALESCE(p.dislikeCount, 0) + :delta WHERE p.id = :id")
|
||||
void adjustDislikeCount(@Param("id") Integer id, @Param("delta") int delta);
|
||||
|
||||
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
|
||||
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
|
||||
"from post p \n" +
|
||||
@@ -31,6 +47,15 @@ public interface PostsRepository extends JpaRepository<Post, Integer> {
|
||||
List<Map<String, Object>> findPostCustomById(@Param("dId") Integer dId);
|
||||
|
||||
|
||||
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
|
||||
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
|
||||
"from post p \n" +
|
||||
"left join user u on p.user_id = u.id \n" +
|
||||
"left join user u2 on p.create_id = u2.id \n" +
|
||||
"where p.discussion_id = :dId and p.parent_id is null order by p.create_time asc"
|
||||
, nativeQuery = true)
|
||||
List<Map<String, Object>> findAllByDIdAndparentIdNullOldest(@Param("dId") Integer dId);
|
||||
|
||||
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
|
||||
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
|
||||
"from post p \n" +
|
||||
@@ -38,7 +63,16 @@ public interface PostsRepository extends JpaRepository<Post, Integer> {
|
||||
"left join user u2 on p.create_id = u2.id \n" +
|
||||
"where p.discussion_id = :dId and p.parent_id is null order by p.create_time desc"
|
||||
, nativeQuery = true)
|
||||
List<Map<String, Object>> findAllByDIdAndparentIdNull(@Param("dId") Integer dId);
|
||||
List<Map<String, Object>> findAllByDIdAndparentIdNullNewest(@Param("dId") Integer dId);
|
||||
|
||||
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
|
||||
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
|
||||
"from post p \n" +
|
||||
"left join user u on p.user_id = u.id \n" +
|
||||
"left join user u2 on p.create_id = u2.id \n" +
|
||||
"where p.discussion_id = :dId and p.parent_id is null order by p.like_count desc, p.create_time desc"
|
||||
, nativeQuery = true)
|
||||
List<Map<String, Object>> findAllByDIdAndparentIdNullHot(@Param("dId") Integer dId);
|
||||
|
||||
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
|
||||
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
|
||||
|
||||
@@ -2,9 +2,11 @@ package com.yaoyuan.jiscuss.repository;
|
||||
|
||||
import com.yaoyuan.jiscuss.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -32,4 +34,18 @@ public interface UsersRepository extends JpaRepository<User, Integer> {
|
||||
|
||||
// REMOVED: checkByUsernameAndPassword — never compare passwords at the SQL layer.
|
||||
// Password validation must go through BCryptPasswordEncoder.matches() in the service/security layer.
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE User u SET u.commentsCount = COALESCE(u.commentsCount, 0) + 1 WHERE u.id = :id")
|
||||
void incrementCommentCount(@Param("id") Integer id);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE User u SET u.discussionsCount = COALESCE(u.discussionsCount, 0) + 1 WHERE u.id = :id")
|
||||
void incrementDiscussionCount(@Param("id") Integer id);
|
||||
|
||||
/** Top active users by comment count (for the ranking sidebar). Shows all users if counts are 0. */
|
||||
@Query("FROM User u ORDER BY COALESCE(u.commentsCount, 0) DESC")
|
||||
List<User> findTop10ActiveUsers(org.springframework.data.domain.Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ public record ApiResponse<T>(int code, String msg, T data) {
|
||||
return new ApiResponse<>(responseCode.getCode(), responseCode.getMsg(), null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> fail(int code, String msg) {
|
||||
return new ApiResponse<>(code, msg, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String message) {
|
||||
return new ApiResponse<>(ResponseCode.SERVICE_ERROR.getCode(), message, null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yaoyuan.jiscuss.service;
|
||||
|
||||
/**
|
||||
* Vote (like / dislike) service for discussions and posts.
|
||||
*
|
||||
* <p>Toggle semantics:
|
||||
* <ul>
|
||||
* <li>If the user has not voted → create a new vote with the requested action.</li>
|
||||
* <li>If the user voted with the same action → cancel the vote (toggle off).</li>
|
||||
* <li>If the user voted with a different action → switch the vote.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return the user's current action after the operation: "like", "dislike", or "none" (cancelled)
|
||||
*/
|
||||
public interface ILikeCollectService {
|
||||
|
||||
/** Vote on a discussion. {@code action} must be "like" or "dislike". */
|
||||
String voteDiscussion(Integer userId, Integer discussionId, String action);
|
||||
|
||||
/** Vote on a post (reply). {@code action} must be "like" or "dislike". */
|
||||
String votePost(Integer userId, Integer postId, String action);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public interface IPostsService {
|
||||
|
||||
List<Post> findOneBy(Integer id);
|
||||
|
||||
List<PostCustom> findPostCustomById(Integer id);
|
||||
List findPostCustomById(Integer id, String sort);
|
||||
|
||||
Post findOneByid(Integer parentId);
|
||||
}
|
||||
|
||||
@@ -27,4 +27,6 @@ public interface IUsersService {
|
||||
List<User> findAllById(Iterable<Integer> ids);
|
||||
|
||||
User update(User user, Integer id);
|
||||
|
||||
List<User> getTopActiveUsers(int limit);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yaoyuan.jiscuss.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Lightweight IP region service.
|
||||
*
|
||||
* <p>Current implementation distinguishes local/private addresses from external ones.
|
||||
* To display accurate province-level location (e.g. "北京", "广东"), replace the
|
||||
* {@code lookupExternal} method with an ip2region XDB lookup or similar offline database.
|
||||
*
|
||||
* <p>Upgrade path:
|
||||
* <ol>
|
||||
* <li>Add {@code org.lionsoul:ip2region:2.7.0} to pom.xml</li>
|
||||
* <li>Place {@code ip2region.xdb} (from the ip2region GitHub release) under
|
||||
* {@code src/main/resources/}</li>
|
||||
* <li>Replace {@code lookupExternal()} with:
|
||||
* {@code Searcher.newWithFileOnly(xdbPath).searchByStr(ip)}</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Service
|
||||
public class IpRegionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IpRegionService.class);
|
||||
|
||||
private static final Set<String> LOCAL_PREFIXES = Set.of(
|
||||
"127.", "0.", "::1", "0:0:0:0:0:0:0:1"
|
||||
);
|
||||
|
||||
private static final Set<String> PRIVATE_PREFIXES = Set.of(
|
||||
"10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
|
||||
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31."
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a display-friendly region label for the given IP address.
|
||||
*
|
||||
* @param ip client IP string (IPv4 or IPv6)
|
||||
* @return region label, e.g. "本地", "局域网", or "未知地区"
|
||||
*/
|
||||
public String getRegion(String ip) {
|
||||
if (ip == null || ip.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
for (String prefix : LOCAL_PREFIXES) {
|
||||
if (ip.startsWith(prefix)) {
|
||||
return "本地";
|
||||
}
|
||||
}
|
||||
for (String prefix : PRIVATE_PREFIXES) {
|
||||
if (ip.startsWith(prefix)) {
|
||||
return "局域网";
|
||||
}
|
||||
}
|
||||
return lookupExternal(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to integrate an offline IP database (e.g. ip2region).
|
||||
* Default implementation returns "未知地区" as a placeholder.
|
||||
*/
|
||||
protected String lookupExternal(String ip) {
|
||||
// TODO: integrate ip2region XDB for accurate province-level lookup
|
||||
return "未知地区";
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.yaoyuan.jiscuss.service.impl;
|
||||
|
||||
import com.yaoyuan.jiscuss.entity.Discussion;
|
||||
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
|
||||
import com.yaoyuan.jiscuss.repository.UsersRepository;
|
||||
import com.yaoyuan.jiscuss.service.IDiscussionsService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Example;
|
||||
@@ -20,6 +21,9 @@ public class DiscussionsServiceImpl implements IDiscussionsService {
|
||||
@Autowired
|
||||
private DiscussionsRepository discussionsRepository;
|
||||
|
||||
@Autowired
|
||||
private UsersRepository usersRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Override
|
||||
public List<Discussion> getAllList() {
|
||||
@@ -29,7 +33,12 @@ public class DiscussionsServiceImpl implements IDiscussionsService {
|
||||
@Transactional
|
||||
@Override
|
||||
public Discussion insert(Discussion discussion) {
|
||||
return discussionsRepository.save(discussion);
|
||||
Discussion saved = discussionsRepository.save(discussion);
|
||||
// Maintain user discussions count
|
||||
if (discussion.getCreateId() != null) {
|
||||
usersRepository.incrementDiscussionCount(discussion.getCreateId());
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.yaoyuan.jiscuss.service.impl;
|
||||
|
||||
import com.yaoyuan.jiscuss.entity.LikeCollect;
|
||||
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
|
||||
import com.yaoyuan.jiscuss.repository.LikeCollectRepository;
|
||||
import com.yaoyuan.jiscuss.repository.PostsRepository;
|
||||
import com.yaoyuan.jiscuss.service.ILikeCollectService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class LikeCollectServiceImpl implements ILikeCollectService {
|
||||
|
||||
@Autowired
|
||||
private LikeCollectRepository likeCollectRepository;
|
||||
|
||||
@Autowired
|
||||
private DiscussionsRepository discussionsRepository;
|
||||
|
||||
@Autowired
|
||||
private PostsRepository postsRepository;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public String voteDiscussion(Integer userId, Integer discussionId, String action) {
|
||||
Optional<LikeCollect> existing = likeCollectRepository.findDiscussionVote(userId, discussionId);
|
||||
if (existing.isPresent()) {
|
||||
LikeCollect vote = existing.get();
|
||||
if (vote.getAction().equals(action)) {
|
||||
// Toggle off: cancel the same action
|
||||
likeCollectRepository.delete(vote);
|
||||
adjustDiscussion(discussionId, action, -1);
|
||||
return "none";
|
||||
} else {
|
||||
// Switch action: undo old, apply new
|
||||
adjustDiscussion(discussionId, vote.getAction(), -1);
|
||||
vote.setAction(action);
|
||||
likeCollectRepository.save(vote);
|
||||
adjustDiscussion(discussionId, action, +1);
|
||||
return action;
|
||||
}
|
||||
} else {
|
||||
// New vote
|
||||
LikeCollect vote = new LikeCollect();
|
||||
vote.setUserId(userId);
|
||||
vote.setDiscussionId(discussionId);
|
||||
vote.setType("discussion");
|
||||
vote.setAction(action);
|
||||
vote.setCreateId(userId);
|
||||
vote.setCreateTime(new Date());
|
||||
likeCollectRepository.save(vote);
|
||||
adjustDiscussion(discussionId, action, +1);
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public String votePost(Integer userId, Integer postId, String action) {
|
||||
Optional<LikeCollect> existing = likeCollectRepository.findPostVote(userId, postId);
|
||||
if (existing.isPresent()) {
|
||||
LikeCollect vote = existing.get();
|
||||
if (vote.getAction().equals(action)) {
|
||||
likeCollectRepository.delete(vote);
|
||||
adjustPost(postId, action, -1);
|
||||
return "none";
|
||||
} else {
|
||||
adjustPost(postId, vote.getAction(), -1);
|
||||
vote.setAction(action);
|
||||
likeCollectRepository.save(vote);
|
||||
adjustPost(postId, action, +1);
|
||||
return action;
|
||||
}
|
||||
} else {
|
||||
LikeCollect vote = new LikeCollect();
|
||||
vote.setUserId(userId);
|
||||
vote.setPostId(postId);
|
||||
vote.setType("post");
|
||||
vote.setAction(action);
|
||||
vote.setCreateId(userId);
|
||||
vote.setCreateTime(new Date());
|
||||
likeCollectRepository.save(vote);
|
||||
adjustPost(postId, action, +1);
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
private void adjustDiscussion(Integer id, String action, int delta) {
|
||||
if ("like".equals(action)) {
|
||||
discussionsRepository.adjustLikeCount(id, delta);
|
||||
} else {
|
||||
discussionsRepository.adjustDislikeCount(id, delta);
|
||||
}
|
||||
}
|
||||
|
||||
private void adjustPost(Integer id, String action, int delta) {
|
||||
if ("like".equals(action)) {
|
||||
postsRepository.adjustLikeCount(id, delta);
|
||||
} else {
|
||||
postsRepository.adjustDislikeCount(id, delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import com.yaoyuan.jiscuss.common.Node;
|
||||
import com.yaoyuan.jiscuss.common.PostCommonUtil;
|
||||
import com.yaoyuan.jiscuss.entity.Post;
|
||||
import com.yaoyuan.jiscuss.entity.custom.PostCustom;
|
||||
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
|
||||
import com.yaoyuan.jiscuss.repository.PostsRepository;
|
||||
import com.yaoyuan.jiscuss.repository.UsersRepository;
|
||||
import com.yaoyuan.jiscuss.service.IPostsService;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -13,6 +15,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -23,6 +26,12 @@ public class PostsServiceImpl implements IPostsService {
|
||||
@Autowired
|
||||
private PostsRepository postsRepository;
|
||||
|
||||
@Autowired
|
||||
private DiscussionsRepository discussionsRepository;
|
||||
|
||||
@Autowired
|
||||
private UsersRepository usersRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Override
|
||||
public List<Post> getAllList() {
|
||||
@@ -48,17 +57,19 @@ public class PostsServiceImpl implements IPostsService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Override
|
||||
public List findPostCustomById(Integer id) {
|
||||
//查询id为1且parentId为null的评论
|
||||
List<Map<String, Object>> firstposts = postsRepository.findAllByDIdAndparentIdNull(id);
|
||||
public List findPostCustomById(Integer id, String sort) {
|
||||
// Choose sort order for top-level posts
|
||||
List<Map<String, Object>> firstposts = switch (sort == null ? "newest" : sort) {
|
||||
case "oldest" -> postsRepository.findAllByDIdAndparentIdNullOldest(id);
|
||||
case "hot" -> postsRepository.findAllByDIdAndparentIdNullHot(id);
|
||||
default -> postsRepository.findAllByDIdAndparentIdNullNewest(id); // newest (root only, create_time desc)
|
||||
};
|
||||
List<PostCustom> firstpostCustomList = PostCommonUtil.getNewPostsObjMap(firstposts);
|
||||
// List<PostCustom> firstpostCustomListNew = PostCommonUtil.getNewPostsObjCustom(firstpostCustomList);
|
||||
|
||||
//查询id为1且parentId不为null的评论
|
||||
List<Map<String, Object>> thenposts = postsRepository.findAllByDIdAndparentIdNotNull(id);
|
||||
List<PostCustom> thenpostCustomList = PostCommonUtil.getNewPostsObjMap(thenposts);
|
||||
|
||||
// List<PostCustom> thenpostCustomListNew = PostCommonUtil.getNewPostsObjCustom(thenpostCustomList);
|
||||
//新建一个Node集合。
|
||||
ArrayList<Node> nodes = new ArrayList<>();
|
||||
//将第一层评论都添加都Node集合中
|
||||
@@ -72,15 +83,27 @@ public class PostsServiceImpl implements IPostsService {
|
||||
//打印回复链表
|
||||
Node.show(list);
|
||||
|
||||
// List<Map<String, Object>> posts = postsRepository.findPostCustomById(id);
|
||||
// List<PostCustom> postCustomList = PostCommonUtil.getNewPostsObjMap(posts);
|
||||
// List<PostCustom> postCustomListNew = PostCommonUtil.getNewPostsObjCustom(postCustomList);
|
||||
return list;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public Post insert(Post post) {
|
||||
return postsRepository.save(post);
|
||||
// Auto-assign floor number within the same transaction to ensure consistency
|
||||
if (post.getDiscussionId() != null) {
|
||||
int nextNumber = postsRepository.findMaxNumberByDiscussionId(post.getDiscussionId()) + 1;
|
||||
post.setNumber(nextNumber);
|
||||
}
|
||||
Post saved = postsRepository.save(post);
|
||||
|
||||
// Maintain discussion comment count and last activity
|
||||
if (post.getDiscussionId() != null) {
|
||||
discussionsRepository.incrementCommentCount(post.getDiscussionId(), post.getCreateId(), new Date());
|
||||
}
|
||||
// Maintain user comment count
|
||||
if (post.getCreateId() != null) {
|
||||
usersRepository.incrementCommentCount(post.getCreateId());
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,13 @@ public class UsersServiceImpl implements IUsersService {
|
||||
return usersRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Override
|
||||
public List<User> getTopActiveUsers(int limit) {
|
||||
return usersRepository.findTop10ActiveUsers(
|
||||
org.springframework.data.domain.PageRequest.of(0, limit));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.yaoyuan.jiscuss.util;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Lightweight User-Agent parser.
|
||||
* Extracts a human-readable browser name and major version without
|
||||
* importing a heavy third-party library.
|
||||
*
|
||||
* <p>Detection order matters: Edge must be checked before Chrome, and
|
||||
* Chrome before Safari, because UA strings commonly contain multiple tokens.
|
||||
*/
|
||||
public final class UserAgentUtils {
|
||||
|
||||
private UserAgentUtils() {}
|
||||
|
||||
private static final Pattern EDGE = Pattern.compile("Edg(?:e|A|iOS)?/([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern FIREFOX = Pattern.compile("Firefox/([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern CHROME = Pattern.compile("Chrome/([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern SAFARI = Pattern.compile("Version/([\\d.]+).*Safari", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern OPERA = Pattern.compile("OPR/([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern IE = Pattern.compile("(?:MSIE |Trident/.*rv:)([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/**
|
||||
* Returns a short browser description, e.g. "Chrome 120", "Firefox 121", "Safari 17".
|
||||
* Returns "未知浏览器" when detection fails.
|
||||
*
|
||||
* @param userAgent the raw User-Agent header value
|
||||
* @return display string, never null
|
||||
*/
|
||||
public static String parseBrowser(String userAgent) {
|
||||
if (userAgent == null || userAgent.isBlank()) {
|
||||
return "未知浏览器";
|
||||
}
|
||||
// Order matters: Edge contains 'Chrome', Chrome contains 'Safari'
|
||||
String r;
|
||||
if ((r = match(EDGE, userAgent, "Edge")) != null) return r;
|
||||
if ((r = match(OPERA, userAgent, "Opera")) != null) return r;
|
||||
if ((r = match(FIREFOX, userAgent, "Firefox")) != null) return r;
|
||||
if ((r = match(CHROME, userAgent, "Chrome")) != null) return r;
|
||||
if ((r = match(SAFARI, userAgent, "Safari")) != null) return r;
|
||||
if ((r = match(IE, userAgent, "IE")) != null) return r;
|
||||
return "未知浏览器";
|
||||
}
|
||||
|
||||
private static String match(Pattern pattern, String ua, String name) {
|
||||
Matcher m = pattern.matcher(ua);
|
||||
if (m.find()) {
|
||||
String version = m.group(1);
|
||||
// Only keep major version number
|
||||
int dotIdx = version.indexOf('.');
|
||||
String major = dotIdx > 0 ? version.substring(0, dotIdx) : version;
|
||||
return name + " " + major;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- V8: Add vote (like/dislike) support and IP/browser meta to posts
|
||||
|
||||
-- likecollect: add explicit action column ('like' or 'dislike')
|
||||
ALTER TABLE likecollect ADD COLUMN action VARCHAR(20);
|
||||
|
||||
-- discussion: add dislike counter
|
||||
ALTER TABLE discussion ADD COLUMN dislike_count INTEGER DEFAULT 0;
|
||||
|
||||
-- post: add per-post vote counters
|
||||
ALTER TABLE post ADD COLUMN like_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE post ADD COLUMN dislike_count INTEGER DEFAULT 0;
|
||||
|
||||
-- indexes for vote lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_likecollect_user_discussion ON likecollect(user_id, discussion_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_likecollect_user_post ON likecollect(user_id, post_id);
|
||||
|
||||
-- post: add IP region and parsed browser name
|
||||
ALTER TABLE post ADD COLUMN ip_region VARCHAR(100);
|
||||
ALTER TABLE post ADD COLUMN browser VARCHAR(200);
|
||||
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
#nprogress{pointer-events:none}#nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}#nprogress .peg{display:block;position:absolute;right:0;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;-webkit-transform:rotate(3deg) translate(0,-4px);-ms-transform:rotate(3deg) translate(0,-4px);transform:rotate(3deg) translate(0,-4px)}#nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}#nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:2px solid transparent;border-top-color:#29d;border-left-color:#29d;border-radius:50%;-webkit-animation:nprogress-spinner 400ms linear infinite;animation:nprogress-spinner 400ms linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent #nprogress .bar,.nprogress-custom-parent #nprogress .spinner{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
|
||||
@@ -0,0 +1 @@
|
||||
!function(n,e){"function"==typeof define&&define.amd?define(e):"object"==typeof exports?module.exports=e():n.NProgress=e()}(this,function(){function n(n,e,t){return e>n?e:n>t?t:n}function e(n){return 100*(-1+n)}function t(n,t,r){var i;return i="translate3d"===c.positionUsing?{transform:"translate3d("+e(n)+"%,0,0)"}:"translate"===c.positionUsing?{transform:"translate("+e(n)+"%,0)"}:{"margin-left":e(n)+"%"},i.transition="all "+t+"ms "+r,i}function r(n,e){var t="string"==typeof n?n:o(n);return t.indexOf(" "+e+" ")>=0}function i(n,e){var t=o(n),i=t+e;r(t,e)||(n.className=i.substring(1))}function s(n,e){var t,i=o(n);r(n,e)&&(t=i.replace(" "+e+" "," "),n.className=t.substring(1,t.length-1))}function o(n){return(" "+(n.className||"")+" ").replace(/\s+/gi," ")}function a(n){n&&n.parentNode&&n.parentNode.removeChild(n)}var u={};u.version="0.2.0";var c=u.settings={minimum:.08,easing:"ease",positionUsing:"",speed:200,trickle:!0,trickleRate:.02,trickleSpeed:800,showSpinner:!0,barSelector:'[role="bar"]',spinnerSelector:'[role="spinner"]',parent:"body",template:'<div class="bar" role="bar"><div class="peg"></div></div><div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'};u.configure=function(n){var e,t;for(e in n)t=n[e],void 0!==t&&n.hasOwnProperty(e)&&(c[e]=t);return this},u.status=null,u.set=function(e){var r=u.isStarted();e=n(e,c.minimum,1),u.status=1===e?null:e;var i=u.render(!r),s=i.querySelector(c.barSelector),o=c.speed,a=c.easing;return i.offsetWidth,l(function(n){""===c.positionUsing&&(c.positionUsing=u.getPositioningCSS()),f(s,t(e,o,a)),1===e?(f(i,{transition:"none",opacity:1}),i.offsetWidth,setTimeout(function(){f(i,{transition:"all "+o+"ms linear",opacity:0}),setTimeout(function(){u.remove(),n()},o)},o)):setTimeout(n,o)}),this},u.isStarted=function(){return"number"==typeof u.status},u.start=function(){u.status||u.set(0);var n=function(){setTimeout(function(){u.status&&(u.trickle(),n())},c.trickleSpeed)};return c.trickle&&n(),this},u.done=function(n){return n||u.status?u.inc(.3+.5*Math.random()).set(1):this},u.inc=function(e){var t=u.status;return t?("number"!=typeof e&&(e=(1-t)*n(Math.random()*t,.1,.95)),t=n(t+e,0,.994),u.set(t)):u.start()},u.trickle=function(){return u.inc(Math.random()*c.trickleRate)},function(){var n=0,e=0;u.promise=function(t){return t&&"resolved"!==t.state()?(0===e&&u.start(),n++,e++,t.always(function(){e--,0===e?(n=0,u.done()):u.set((n-e)/n)}),this):this}}(),u.render=function(n){if(u.isRendered())return document.getElementById("nprogress");i(document.documentElement,"nprogress-busy");var t=document.createElement("div");t.id="nprogress",t.innerHTML=c.template;var r,s=t.querySelector(c.barSelector),o=n?"-100":e(u.status||0),l=document.querySelector(c.parent);return f(s,{transition:"all 0 linear",transform:"translate3d("+o+"%,0,0)"}),c.showSpinner||(r=t.querySelector(c.spinnerSelector),r&&a(r)),l!=document.body&&i(l,"nprogress-custom-parent"),l.appendChild(t),t},u.remove=function(){s(document.documentElement,"nprogress-busy"),s(document.querySelector(c.parent),"nprogress-custom-parent");var n=document.getElementById("nprogress");n&&a(n)},u.isRendered=function(){return!!document.getElementById("nprogress")},u.getPositioningCSS=function(){var n=document.body.style,e="WebkitTransform"in n?"Webkit":"MozTransform"in n?"Moz":"msTransform"in n?"ms":"OTransform"in n?"O":"";return e+"Perspective"in n?"translate3d":e+"Transform"in n?"translate":"margin"};var l=function(){function n(){var t=e.shift();t&&t(n)}var e=[];return function(t){e.push(t),1==e.length&&n()}}(),f=function(){function n(n){return n.replace(/^-ms-/,"ms-").replace(/-([\da-z])/gi,function(n,e){return e.toUpperCase()})}function e(n){var e=document.body.style;if(n in e)return n;for(var t,r=i.length,s=n.charAt(0).toUpperCase()+n.slice(1);r--;)if(t=i[r]+s,t in e)return t;return n}function t(t){return t=n(t),s[t]||(s[t]=e(t))}function r(n,e,r){e=t(e),n.style[e]=r}var i=["Webkit","O","Moz","ms"],s={};return function(n,e){var t,i,s=arguments;if(2==s.length)for(t in e)i=e[t],void 0!==i&&e.hasOwnProperty(t)&&r(n,t,i);else r(n,s[1],s[2])}}();return u});
|
||||
@@ -1,3 +1,36 @@
|
||||
/* ── CSS custom properties for theme switching ────────────────────────────── */
|
||||
:root {
|
||||
--bg-page: #f7f8fa;
|
||||
--bg-card: #ffffff;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #888888;
|
||||
--accent: #2185d0;
|
||||
--border: rgba(34, 36, 38, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-page: #1b1c1d;
|
||||
--bg-card: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #999999;
|
||||
--accent: #6435c9;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body { background-color: var(--bg-page) !important; color: var(--text-primary); }
|
||||
|
||||
/* Apply card background where Semantic UI uses white */
|
||||
[data-theme="dark"] .ui.card,
|
||||
[data-theme="dark"] .ui.segment,
|
||||
[data-theme="dark"] #context2,
|
||||
[data-theme="dark"] .ui.menu { background-color: var(--bg-card) !important; color: var(--text-primary) !important; }
|
||||
|
||||
[data-theme="dark"] .ui.header,
|
||||
[data-theme="dark"] .ui.dividing.header { color: var(--text-primary) !important; border-bottom-color: var(--border) !important; }
|
||||
|
||||
[data-theme="dark"] a { color: var(--accent) !important; }
|
||||
|
||||
/* ── Layout helpers ───────────────────────────────────────────────────────── */
|
||||
.nullright {
|
||||
float: right;
|
||||
}
|
||||
@@ -1,30 +1,25 @@
|
||||
<#--jquery-->
|
||||
<#--NProgress - page load progress bar (local first)-->
|
||||
<link rel="stylesheet" type="text/css" href="/static/lib/nprogress.min.css"/>
|
||||
<script src="/static/lib/nprogress.min.js"></script>
|
||||
<script>if(typeof NProgress !== 'undefined') NProgress.start();</script>
|
||||
|
||||
<#--jquery (local first, CDN fallback)-->
|
||||
<link rel="icon" type="image/png" href="/static/assets/images/logo.png">
|
||||
<script crossorigin="anonymous" integrity="sha384-vk5WoKIaW/vJyUAd9n/wmopsmNhiy+L2Z+SBxGYnUkunIxVxAv/UtMOhba/xskxh"
|
||||
src="https://lib.baomitu.com/jquery/3.4.1/jquery.min.js"></script>
|
||||
<script src="/static/lib/jquery.min.js"></script>
|
||||
<script>window.jQuery || document.write('<script src="https://lib.baomitu.com/jquery/3.4.1/jquery.min.js"><\/script>')</script>
|
||||
|
||||
<#--semantic-ui-->
|
||||
<link rel="stylesheet" type="text/css" href="/static/semanticui/semantic.css">
|
||||
<#-- <script type="text/javascript" src="static/jquery/jquery-3.4.1.min.js"></script>-->
|
||||
<script type="text/javascript" src="/static/semanticui/semantic.js"></script>
|
||||
|
||||
<#--<link crossorigin="anonymous" integrity="sha384-ATvSpJEmy1egycrmomcFxVn4Z0A6rfjwlzDQrts/1QRerQhR9EEpEYtdysLpQPuQ"-->
|
||||
<#-- href="https://lib.baomitu.com/semantic-ui/2.4.1/semantic.min.css" rel="stylesheet">-->
|
||||
<link rel="stylesheet" type="text/css" href="/static/semanticui/my.css">
|
||||
<#--<script crossorigin="anonymous" integrity="sha384-6urqf2sgCGDfIXcoxTUOVIoQV+jFr/Zuc4O2wCRS6Rnd8w0OJ17C4Oo3PuXu8ZtF"-->
|
||||
<#-- src="https://lib.baomitu.com/semantic-ui/2.4.1/semantic.min.js"></script>-->
|
||||
|
||||
<#--tinymce-->
|
||||
<script crossorigin="anonymous" integrity="sha384-CpsBIlOAWHuSRRN235sCBzEeKN6hLT6SpOGRkGadKpYj0gDP7+s3Q8pC38z8uGHH"
|
||||
src="https://lib.baomitu.com/tinymce/5.1.1/tinymce.min.js"></script>
|
||||
|
||||
<#--tinymce (CDN)-->
|
||||
<script src="https://lib.baomitu.com/tinymce/5.1.1/tinymce.min.js"></script>
|
||||
|
||||
<#--layx-->
|
||||
<link href="/static/layx/layx.min.css?b14794a8a3baf3e8b58e" rel="stylesheet">
|
||||
|
||||
<script type="text/javascript" src="/static/layx/layx.min.js?b14794a8a3baf3e8b58e"></script>
|
||||
|
||||
<script type="text/javascript" charset="UTF-8" src="/static/js/comm/util.js"></script>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -45,4 +45,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>if(typeof NProgress !== 'undefined') NProgress.done();</script>
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
<input type="text" placeholder="搜索">
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<a id="themeToggle" title="切换主题" style="cursor:pointer; font-size:1.2em; color:inherit;">
|
||||
<i class="moon icon" id="themeIcon"></i> 主题
|
||||
</a>
|
||||
</div>
|
||||
<div class="item" id="userlogin">
|
||||
<#if username??>
|
||||
<form class="ui large form" action="/logout" id="logoutForm" method="post" style="display: none;">
|
||||
@@ -40,3 +45,31 @@
|
||||
|
||||
|
||||
<script type="text/javascript" charset="UTF-8" src="/static/js/user/header.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var saved = localStorage.getItem('jiscuss-theme') || 'light';
|
||||
if (saved === 'dark') { document.documentElement.setAttribute('data-theme', 'dark'); }
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var btn = document.getElementById('themeToggle');
|
||||
var icon = document.getElementById('themeIcon');
|
||||
function applyTheme(t) {
|
||||
if (t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
icon.className = 'sun icon';
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
icon.className = 'moon icon';
|
||||
}
|
||||
}
|
||||
applyTheme(saved);
|
||||
if (btn) {
|
||||
btn.onclick = function() {
|
||||
var current = localStorage.getItem('jiscuss-theme') || 'light';
|
||||
var next = current === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('jiscuss-theme', next);
|
||||
applyTheme(next);
|
||||
};
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -45,13 +45,21 @@
|
||||
</div>
|
||||
|
||||
<div class="ui labeled button" tabindex="0" style=" margin-top: 30px;">
|
||||
<div class="ui red button">
|
||||
<div class="ui red button" id="likeBtn" onclick="voteDiscussion('like')">
|
||||
<i class="heart icon"></i> 赞这个主题
|
||||
</div>
|
||||
<a class="ui basic red left pointing label">
|
||||
<a class="ui basic red left pointing label" id="likeCount">
|
||||
${discussions.likeCount!0}
|
||||
</a>
|
||||
</div>
|
||||
<div class="ui labeled button" tabindex="0" style="margin-left:8px; margin-top: 30px;">
|
||||
<div class="ui grey button" id="dislikeBtn" onclick="voteDiscussion('dislike')">
|
||||
<i class="thumbs down icon"></i> 踩
|
||||
</div>
|
||||
<a class="ui basic grey left pointing label" id="dislikeCount">
|
||||
${discussions.dislikeCount!0}
|
||||
</a>
|
||||
</div>
|
||||
<span style="margin-left:12px; color:#888; font-size:0.9em;">
|
||||
<i class="eye icon"></i> ${discussions.viewCount!0} 浏览
|
||||
<i class="comment icon"></i> ${discussions.commentsCount!0} 回复
|
||||
@@ -90,7 +98,14 @@
|
||||
</div>
|
||||
|
||||
<div class="ui threaded comments">
|
||||
<h3 class="ui dividing header">评论区</h3>
|
||||
<h3 class="ui dividing header">
|
||||
评论区
|
||||
<div class="ui mini buttons" style="float:right; font-size:0.75em;">
|
||||
<a class="ui button <#if sort == 'oldest'>active</#if>" href="?id=${discussions.id}&sort=oldest">最早</a>
|
||||
<a class="ui button <#if sort == 'newest'>active</#if>" href="?id=${discussions.id}&sort=newest">最新</a>
|
||||
<a class="ui button <#if sort == 'hot'>active</#if>" href="?id=${discussions.id}&sort=hot">最热</a>
|
||||
</div>
|
||||
</h3>
|
||||
<input type="hidden" name="postId" id="postId" value=""/>
|
||||
|
||||
<!-- 定义遍历方法 -->
|
||||
@@ -105,15 +120,20 @@
|
||||
<img src="/static/assets/images/logo.png">
|
||||
</a>
|
||||
<div class="content">
|
||||
<a class="author">${post.username}</a>
|
||||
<a class="author user-card-trigger" data-user-id="${post.createId!''}">${post.username}</a>
|
||||
<span style="color:#aaa; font-size:0.8em; margin-left:6px;">#${post.number!''}</span>
|
||||
<div class="metadata">
|
||||
<span class="date">${post.createTime}</span>
|
||||
<#if post.ipRegion?? && post.ipRegion != ""><span style="margin-left:6px;"><i class="map marker alternate icon"></i>${post.ipRegion}</span></#if>
|
||||
<#if post.browser?? && post.browser != ""><span style="margin-left:6px;"><i class="chrome icon"></i>${post.browser}</span></#if>
|
||||
</div>
|
||||
<div class="text">
|
||||
${post.content}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="reply" onclick="replyThis('${post.username}', '${post.id}')">回复</a>
|
||||
<a onclick="votePost(${post.id}, 'like')" style="cursor:pointer;"><i class="thumbs up outline icon"></i>${(post.likeCount!0)?c}</a>
|
||||
<a onclick="votePost(${post.id}, 'dislike')" style="cursor:pointer;"><i class="thumbs down outline icon"></i>${(post.dislikeCount!0)?c}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comments">
|
||||
@@ -127,15 +147,20 @@
|
||||
<img src="/static/assets/images/logo.png">
|
||||
</a>
|
||||
<div class="content">
|
||||
<a class="author">${post.username}</a>
|
||||
<a class="author user-card-trigger" data-user-id="${post.createId!''}">${post.username}</a>
|
||||
<span style="color:#aaa; font-size:0.8em; margin-left:6px;">#${post.number!''}</span>
|
||||
<div class="metadata">
|
||||
<span class="date">${post.createTime}</span>
|
||||
<#if post.ipRegion?? && post.ipRegion != ""><span style="margin-left:6px;"><i class="map marker alternate icon"></i>${post.ipRegion}</span></#if>
|
||||
<#if post.browser?? && post.browser != ""><span style="margin-left:6px;"><i class="chrome icon"></i>${post.browser}</span></#if>
|
||||
</div>
|
||||
<div class="text">
|
||||
${post.content}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="reply" onclick="replyThis('${post.username}', '${post.id}')">回复</a>
|
||||
<a onclick="votePost(${post.id}, 'like')" style="cursor:pointer;"><i class="thumbs up outline icon"></i>${(post.likeCount!0)?c}</a>
|
||||
<a onclick="votePost(${post.id}, 'dislike')" style="cursor:pointer;"><i class="thumbs down outline icon"></i>${(post.dislikeCount!0)?c}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,28 +288,23 @@
|
||||
|
||||
|
||||
<div class="mobile only sixteen wide column">
|
||||
<div class="ui segment">Jiscuss手机可见,内容正在编写中,pc端可正常访问</div>
|
||||
<div class="ui pagination menu">
|
||||
<a class="icon item">
|
||||
<i class="left chevron icon"></i>
|
||||
</a>
|
||||
<a class="item">
|
||||
1
|
||||
</a>
|
||||
<a class="item">
|
||||
2
|
||||
</a>
|
||||
<div class="disabled item">
|
||||
...
|
||||
<div id="context2" style="padding: 1em; background: #fff; border-radius: 0.28571429rem; border: 1px solid rgba(34, 36, 38, 0.15);">
|
||||
<h3 class="ui header"><i class="edit outline icon"></i>${discussions.title}</h3>
|
||||
<div class="ui main text container">${discussions.content}</div>
|
||||
<div style="margin-top:1em; color:#888; font-size:0.85em;">
|
||||
<i class="eye icon"></i> ${discussions.viewCount!0} 浏览
|
||||
<i class="comment icon"></i> ${discussions.commentsCount!0} 回复
|
||||
</div>
|
||||
<div class="ui threaded comments" style="margin-top:1em;">
|
||||
<h4 class="ui dividing header">评论区</h4>
|
||||
<@bpTree posts=posts />
|
||||
<form class="ui reply form" style="margin-top:1em;">
|
||||
<div class="field"><textarea id="postContentMobile"></textarea></div>
|
||||
<div class="ui blue labeled submit icon button" onclick="addPostMobile()">
|
||||
<i class="icon edit"></i> 添加评论
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<a class="item">
|
||||
10
|
||||
</a>
|
||||
<a class="item">
|
||||
11
|
||||
</a>
|
||||
<a class="icon item"> <i class="right chevron icon"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -303,7 +323,71 @@
|
||||
console.log('已经登陆:' + username);
|
||||
</#if>
|
||||
setDiscussionsId('${discussions.id}');
|
||||
|
||||
// User card — custom fixed-position tooltip (more reliable than Semantic UI popup)
|
||||
var cardCache = {};
|
||||
var $card = $('<div id="jUserCard" style="position:fixed;background:#fff;border:1px solid #ddd;border-radius:6px;padding:12px 16px;box-shadow:0 4px 16px rgba(0,0,0,.15);z-index:9999;min-width:180px;display:none;pointer-events:none;"></div>').appendTo('body');
|
||||
|
||||
function showCard(u, x, y) {
|
||||
$card.html(
|
||||
'<div style="font-weight:700;font-size:1em;margin-bottom:4px;">' + (u.username || '') + '</div>' +
|
||||
'<div style="color:#888;font-size:.88em;">发帖: ' + (u.discussionsCount || 0) +
|
||||
' 回复: ' + (u.commentsCount || 0) + '</div>'
|
||||
).css({ left: x + 14, top: y + 14 }).show();
|
||||
}
|
||||
|
||||
$(document).on('mouseenter', '.user-card-trigger', function(e) {
|
||||
var userId = $(this).data('user-id');
|
||||
if (!userId) return;
|
||||
if (cardCache[userId]) { showCard(cardCache[userId], e.clientX, e.clientY); return; }
|
||||
$.getJSON('/user_api/user/' + userId, function(res) {
|
||||
if (res && res.code === 10000 && res.data) {
|
||||
cardCache[userId] = res.data;
|
||||
showCard(res.data, e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
}).on('mousemove', '.user-card-trigger', function(e) {
|
||||
if ($card.is(':visible')) $card.css({ left: e.clientX + 14, top: e.clientY + 14 });
|
||||
}).on('mouseleave', '.user-card-trigger', function() {
|
||||
$card.hide();
|
||||
});
|
||||
});
|
||||
|
||||
function voteDiscussion(action) {
|
||||
var token = $("meta[name='_csrf']").attr("content");
|
||||
var header = $("meta[name='_csrf_header']").attr("content");
|
||||
$.ajax({
|
||||
url: '/api/discussions/${discussions.id}/vote?action=' + action,
|
||||
type: 'POST',
|
||||
beforeSend: function(xhr) { xhr.setRequestHeader(header, token); },
|
||||
success: function(res) {
|
||||
if (res.code === 10000) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(res.msg || '操作失败,请先登录');
|
||||
}
|
||||
},
|
||||
error: function() { alert('请先登录'); }
|
||||
});
|
||||
}
|
||||
|
||||
function votePost(postId, action) {
|
||||
var token = $("meta[name='_csrf']").attr("content");
|
||||
var header = $("meta[name='_csrf_header']").attr("content");
|
||||
$.ajax({
|
||||
url: '/api/posts/' + postId + '/vote?action=' + action,
|
||||
type: 'POST',
|
||||
beforeSend: function(xhr) { xhr.setRequestHeader(header, token); },
|
||||
success: function(res) {
|
||||
if (res.code === 10000) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(res.msg || '操作失败,请先登录');
|
||||
}
|
||||
},
|
||||
error: function() { alert('请先登录'); }
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<script type="text/javascript" charset="UTF-8" src="/static/js/user/discussions.js"></script>
|
||||
|
||||
@@ -226,32 +226,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui card black ">
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">预留功能</div>
|
||||
<div class="header">🏆 活跃用户排行</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h4 class="ui sub header">Jiscuss</h4>
|
||||
<div class="ui small feed">
|
||||
<div class="event">
|
||||
<div class="ui middle aligned divided list">
|
||||
<#if topUsers?? && topUsers?size gt 0>
|
||||
<#list topUsers as u>
|
||||
<div class="item">
|
||||
<img class="ui avatar image" src="/static/assets/images/logo.png">
|
||||
<div class="content">
|
||||
<div class="summary">
|
||||
<a>Jiscuss</a> 会持续更新, <a>Jiscuss</a> 将一直更新完善,感谢支持!
|
||||
<a class="header">${u.username}</a>
|
||||
<div class="description">${u.commentsCount!0} 回复</div>
|
||||
</div>
|
||||
</div>
|
||||
</#list>
|
||||
<#else>
|
||||
<div class="item" style="color:#aaa;">暂无数据</div>
|
||||
</#if>
|
||||
</div>
|
||||
|
||||
<div class="event">
|
||||
<div class="content">
|
||||
<div class="summary">
|
||||
<a>2019</a> 测试内容
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<button class="ui button">预留按钮</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user