9 Commits

Author SHA1 Message Date
Chuyaoyuan 3c1076834b fix 2026-05-13 19:27:06 +08:00
Chuyaoyuan aeb360bc8a Update admin UI overhaul, scores , tooltip/version fixes 2026-05-13 19:16:34 +08:00
Chuyaoyuan ab9749a111 feat: 积分/等级系统
- V10 migration: add score column, back-fill from activity counts, recompute level
- IScoreService + ScoreServiceImpl: addScore() atomically updates score and level in DB
- UsersRepository.addScore(): JPQL UPDATE with inline CASE level computation
- Level tiers: 0新手(<50) 1学徒(50) 2熟手(200) 3达人(500) 4专家(1000) 5大神(2000+)
- Score events:
  - Post discussion: +10 (UserPostController)
  - Post reply: +5 (UserPostController)
  - Discussion/post liked: +2 to author; unliked/undone: -2 (LikeCollectServiceImpl)
  - Score self-like guard: no points awarded when user votes on own content
- user-card API: expose level + score fields
- user.ftl: replace raw level number with color-coded Lv.N badge + 积分 stat
- discussions.ftl + index.ftl hover cards: show level badge and score

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 18:30:52 +08:00
Chuyaoyuan aeb0866ec1 Update Search footer header index user 2026-05-13 17:40:26 +08:00
Chuyaoyuan cda8a3e0ee Update UI 2026-05-13 15:22:39 +08:00
Chuyaoyuan 28eb17f60b front ui fix 2026-05-13 14:39:57 +08:00
Chuyaoyuan 9e42918d5e Inbox Message & bugs 2026-05-13 13:58:08 +08:00
Chuyaoyuan 4dfd27c785 update theme and user top 2026-05-12 18:49:34 +08:00
Chuyaoyuan ba8c1e3be3 update fix 2026-05-09 11:35:30 +08:00
72 changed files with 3183 additions and 460 deletions
View File
@@ -3,9 +3,11 @@ package com.yaoyuan.jiscuss;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableCaching
@EnableAsync
public class JiccussApplication {
public static void main(String[] args) {
@@ -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) {
@@ -88,7 +88,7 @@ public class WebSecurityConfig {
auth.requestMatchers(
"/css/**", "/js/**", "/images/**", "/static/**", "/favicon.ico",
"/login/**", "/register", "/registerDo", "/initUserData",
"/", "/main", "/index", "/getdiscussionsbyid",
"/", "/main", "/index", "/getdiscussionsbyid", "/search", "/profile",
// SpringDoc OpenAPI UI and spec endpoint (developer tools, no sensitive data)
"/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**"
).permitAll();
@@ -55,6 +55,7 @@ public class AdminSystemController extends BaseController {
private final UpgradeLogRepository upgradeLogRepository;
private final AuditLogRepository auditLogRepository;
private final AuditLogService auditLogService;
private final com.yaoyuan.jiscuss.service.IScoreService scoreService;
public AdminSystemController(
UsersRepository usersRepository,
@@ -65,7 +66,8 @@ public class AdminSystemController extends BaseController {
UserRoleRepository userRoleRepository,
UpgradeLogRepository upgradeLogRepository,
AuditLogRepository auditLogRepository,
AuditLogService auditLogService) {
AuditLogService auditLogService,
com.yaoyuan.jiscuss.service.IScoreService scoreService) {
this.usersRepository = usersRepository;
this.discussionsRepository = discussionsRepository;
this.postsRepository = postsRepository;
@@ -75,6 +77,7 @@ public class AdminSystemController extends BaseController {
this.upgradeLogRepository = upgradeLogRepository;
this.auditLogRepository = auditLogRepository;
this.auditLogService = auditLogService;
this.scoreService = scoreService;
}
@GetMapping("/admin/home")
@@ -294,6 +297,27 @@ public class AdminSystemController extends BaseController {
return "admin/themes";
}
/** 积分管理 — list all users sorted by score desc. */
@GetMapping("/admin/scores")
public String scores(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "scores");
List<User> users = usersRepository.findAll(Sort.by(Sort.Direction.DESC, "score"));
map.put("users", users);
return "admin/scores";
}
/** Manually adjust a user's score (admin operation). */
@PostMapping("/admin/scores/{id}/adjust")
public String adjustScore(@PathVariable Integer id,
@RequestParam int delta,
HttpServletRequest request) {
UserInfo operator = getUserInfo(request);
scoreService.addScore(id, delta);
auditLogService.log(operator, "score_adjust", "user", id,
"score " + (delta >= 0 ? "+" : "") + delta);
return "redirect:/admin/scores";
}
private void fillCommon(HttpServletRequest request, ModelMap map, String active) {
UserInfo user = getUserInfo(request);
map.put("adminName", user == null ? "admin" : user.getUsername());
@@ -327,13 +351,30 @@ public class AdminSystemController extends BaseController {
result.put("java", System.getProperty("java.version"));
result.put("spring", implVersion("org.springframework.core.SpringVersion"));
result.put("hibernate", implVersion("org.hibernate.Version"));
result.put("flyway", implVersion("org.flywaydb.core.Flyway"));
result.put("druid", implVersion("com.alibaba.druid.pool.DruidDataSource"));
result.put("flyway", mavenVersion("org.flywaydb", "flyway-core",
implVersion("org.flywaydb.core.Flyway")));
result.put("druid", mavenVersion("com.alibaba", "druid-spring-boot-3-starter",
mavenVersion("com.alibaba", "druid",
implVersion("com.alibaba.druid.pool.DruidDataSource"))));
result.put("ehcache", implVersion("org.ehcache.CacheManager"));
result.put("springdoc", implVersion("org.springdoc.core.configuration.SpringDocConfiguration"));
return result;
}
/** Read version from META-INF/maven pom.properties (works in fat JARs). Falls back to {@code fallback}. */
private String mavenVersion(String groupId, String artifactId, String fallback) {
String path = "/META-INF/maven/" + groupId + "/" + artifactId + "/pom.properties";
try (java.io.InputStream is = getClass().getResourceAsStream(path)) {
if (is != null) {
java.util.Properties props = new java.util.Properties();
props.load(is);
String v = props.getProperty("version");
if (v != null && !v.isBlank()) return v;
}
} catch (Exception ignored) {}
return fallback;
}
private String implVersion(String className) {
try {
Class<?> clazz = Class.forName(className);
@@ -0,0 +1,30 @@
package com.yaoyuan.jiscuss.controller;
import com.yaoyuan.jiscuss.entity.UserInfo;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
/**
* Injects common model attributes (e.g. isAdmin) into every rendered view.
*/
@ControllerAdvice
public class GlobalModelAdvice {
@ModelAttribute("isAdmin")
public boolean isAdmin(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) return false;
Object ctx = session.getAttribute("SPRING_SECURITY_CONTEXT");
if (!(ctx instanceof SecurityContext sc)) return false;
Authentication auth = sc.getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof UserInfo)) return false;
return auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_ADMIN"));
}
}
@@ -1,15 +1,115 @@
package com.yaoyuan.jiscuss.controller;
import com.yaoyuan.jiscuss.dto.InboxItem;
import com.yaoyuan.jiscuss.entity.Message;
import com.yaoyuan.jiscuss.entity.Notification;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.service.IMessageService;
import com.yaoyuan.jiscuss.service.INotificationService;
import com.yaoyuan.jiscuss.service.IUsersService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList;
import java.util.List;
/**
* @author Chuyaoyuan
* 用户消息控制器
* 站内私信与通知控制器
*/
@Controller
public class UserMsgController extends BaseController {
/**
* 首页最新消息
*/
@Autowired
private IMessageService messageService;
@Autowired
private INotificationService notificationService;
@Autowired
private IUsersService usersService;
/** 收件箱:对话列表 */
@GetMapping("/messages")
public String inbox(HttpServletRequest request, ModelMap map) {
UserInfo user = getUserInfo(request);
if (user == null) return "redirect:/login";
List<Message> inboxList = messageService.getInbox(user.getId());
List<InboxItem> items = new ArrayList<>();
for (Message msg : inboxList) {
Integer otherId = msg.getSenderId().equals(user.getId()) ? msg.getReceiverId() : msg.getSenderId();
User other = usersService.findOne(otherId);
String rawName = (other != null) ? other.getUsername() : null;
String otherName = (rawName != null && !rawName.isBlank()) ? rawName : "用户" + otherId;
boolean hasUnread = !Boolean.TRUE.equals(msg.getIsRead()) && msg.getReceiverId().equals(user.getId());
items.add(new InboxItem(otherId, otherName, msg.getContent(), msg.getCreateTime(), hasUnread));
}
map.put("username", user.getUsername());
map.put("currentUserId", user.getId());
map.put("inboxItems", items);
map.put("unreadMsgCount", messageService.countUnread(user.getId()));
map.put("unreadNotifCount", notificationService.countUnread(user.getId()));
return "messages";
}
/** 对话详情页 */
@GetMapping("/messages/{otherId}")
public String conversation(@PathVariable Integer otherId, HttpServletRequest request, ModelMap map) {
UserInfo user = getUserInfo(request);
if (user == null) return "redirect:/login";
messageService.markConversationRead(user.getId(), otherId);
List<Message> messages = messageService.getConversation(user.getId(), otherId);
User other = usersService.findOne(otherId);
map.put("username", user.getUsername());
map.put("currentUserId", user.getId());
map.put("otherId", otherId);
map.put("otherUsername", other != null ? other.getUsername() : "用户" + otherId);
map.put("messages", messages);
map.put("unreadMsgCount", messageService.countUnread(user.getId()));
map.put("unreadNotifCount", notificationService.countUnread(user.getId()));
return "conversation";
}
/** 通知列表页 */
@GetMapping("/notifications")
public String notifications(@RequestParam(defaultValue = "0") int page,
HttpServletRequest request, ModelMap map) {
UserInfo user = getUserInfo(request);
if (user == null) return "redirect:/login";
notificationService.markAllRead(user.getId());
Page<Notification> notifs = notificationService.listByUser(user.getId(), page, 20);
List<String> fromUsernames = new ArrayList<>();
for (Notification n : notifs.getContent()) {
if (n.getFromUserId() != null) {
User from = usersService.findOne(n.getFromUserId());
fromUsernames.add(from != null ? from.getUsername() : "用户" + n.getFromUserId());
} else {
fromUsernames.add("系统");
}
}
map.put("username", user.getUsername());
map.put("notifications", notifs.getContent());
map.put("fromUsernames", fromUsernames);
map.put("totalPages", notifs.getTotalPages());
map.put("currentPage", page);
map.put("unreadMsgCount", messageService.countUnread(user.getId()));
map.put("unreadNotifCount", 0L);
return "notifications";
}
}
@@ -1,67 +1,153 @@
package com.yaoyuan.jiscuss.controller;
import com.yaoyuan.jiscuss.entity.Discussion;
import com.yaoyuan.jiscuss.entity.Post;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.service.IDiscussionsService;
import com.yaoyuan.jiscuss.service.ITagsService;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.repository.LikeCollectRepository;
import com.yaoyuan.jiscuss.repository.PostsRepository;
import com.yaoyuan.jiscuss.service.IMessageService;
import com.yaoyuan.jiscuss.service.INotificationService;
import com.yaoyuan.jiscuss.service.IUsersService;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
/**
* @author Chuyaoyuan
* @Title:
* @Package com.yaoyuan.jiscuss.controller
* @Description:
* @date
*/
@Controller
public class UserPageController extends BaseController {
private static Logger logger = LoggerFactory.getLogger(UserPageController.class);
private static final Logger logger = LoggerFactory.getLogger(UserPageController.class);
@Autowired
private IUsersService usersService;
@Autowired
private IDiscussionsService discussionsService;
private DiscussionsRepository discussionsRepository;
@Autowired
private ITagsService tagsService;
private PostsRepository postsRepository;
@Autowired
private LikeCollectRepository likeCollectRepository;
@Autowired
private IMessageService messageService;
@Autowired
private INotificationService notificationService;
/**
* 用户页
*
* @return
* 用户个人主
*/
@RequestMapping("/user")
public String user(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) {
public String user(@RequestParam(defaultValue = "discussion") String type,
HttpServletRequest request, ModelMap map) {
// type 判断
if (type.equals("discussion")) {
map.put("discussion", "active");
UserInfo currentUser = getUserInfo(request);
if (currentUser == null) return "redirect:/login";
User userEntity = usersService.findOne(currentUser.getId());
long liveDiscCount = discussionsRepository.countByStartUserId(currentUser.getId());
long liveReplyCount = postsRepository.countByCreateId(currentUser.getId());
map.put("username", currentUser.getUsername());
map.put("userEntity", userEntity);
map.put("liveDiscCount", liveDiscCount);
map.put("liveReplyCount", liveReplyCount);
map.put("type", type);
map.put("unreadMsgCount", messageService.countUnread(currentUser.getId()));
map.put("unreadNotifCount", notificationService.countUnread(currentUser.getId()));
if ("discussion".equals(type)) {
Page<Discussion> discussions = discussionsRepository.findByStartUserIdOrderByStartTimeDesc(
currentUser.getId(), PageRequest.of(0, 20));
map.put("discussions", discussions.getContent());
} else if ("reply".equals(type)) {
Page<Post> posts = postsRepository.findByCreateIdOrderByCreateTimeDesc(
currentUser.getId(), PageRequest.of(0, 20));
map.put("posts", posts.getContent());
// Attach discussion titles
List<String> discTitles = new ArrayList<>();
for (Post p : posts.getContent()) {
if (p.getDiscussionId() != null) {
Discussion d = discussionsRepository.findById(p.getDiscussionId()).orElse(null);
discTitles.add(d != null ? d.getTitle() : "");
} else {
discTitles.add("");
}
}
map.put("discTitles", discTitles);
} else if ("like".equals(type)) {
List<Integer> likedIds = likeCollectRepository.findLikedDiscussionIdsByUser(currentUser.getId());
List<Discussion> liked = new ArrayList<>();
for (Integer id : likedIds) {
Discussion d = discussionsRepository.findById(id).orElse(null);
if (d != null) liked.add(d);
}
map.put("likedDiscussions", liked);
}
if (type.equals("change")) {
map.put("change", "active");
}
map.put("isOwn", true);
return "user";
}
if (type.equals("like")) {
map.put("like", "active");
}
/**
* 公开用户主页 /profile?uid=xxx (无需登录)
*/
@GetMapping("/profile")
public String profile(@RequestParam Integer uid,
@RequestParam(defaultValue = "discussion") String type,
HttpServletRequest request, ModelMap map) {
User userEntity = usersService.findOne(uid);
if (userEntity == null) return "redirect:/";
UserInfo user = getUserInfo(request);
if (user != null) {
map.put("username", user.getUsername());
map.put("data", "Jiscuss 用户:" + user.getUsername());
}
long liveDiscCount = discussionsRepository.countByStartUserId(uid);
long liveReplyCount = postsRepository.countByCreateId(uid);
map.put("username", userEntity.getUsername());
map.put("userEntity", userEntity);
map.put("liveDiscCount", liveDiscCount);
map.put("liveReplyCount", liveReplyCount);
map.put("type", type);
map.put("isOwn", false);
if ("discussion".equals(type)) {
Page<Discussion> discussions = discussionsRepository.findByStartUserIdOrderByStartTimeDesc(
uid, PageRequest.of(0, 20));
map.put("discussions", discussions.getContent());
} else if ("reply".equals(type)) {
Page<Post> posts = postsRepository.findByCreateIdOrderByCreateTimeDesc(
uid, PageRequest.of(0, 20));
map.put("posts", posts.getContent());
List<String> discTitles = new ArrayList<>();
for (Post p : posts.getContent()) {
Discussion d = p.getDiscussionId() != null
? discussionsRepository.findById(p.getDiscussionId()).orElse(null) : null;
discTitles.add(d != null ? d.getTitle() : "");
}
map.put("discTitles", discTitles);
} else if ("like".equals(type)) {
List<Integer> likedIds = likeCollectRepository.findLikedDiscussionIdsByUser(uid);
List<Discussion> liked = new ArrayList<>();
for (Integer id : likedIds) {
Discussion d = discussionsRepository.findById(id).orElse(null);
if (d != null) liked.add(d);
}
map.put("likedDiscussions", liked);
}
return "user";
}
@@ -7,15 +7,21 @@ import com.yaoyuan.jiscuss.entity.Tag;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.entity.custom.DiscussionCustom;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.repository.PostsRepository;
import com.yaoyuan.jiscuss.service.IDiscussionsService;
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;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.PostMapping;
@@ -56,6 +62,21 @@ public class UserPostController extends BaseController {
@Autowired
private IUsersService usersService;
@Autowired
private DiscussionsRepository discussionsRepository;
@Autowired
private PostsRepository postsRepository;
@Autowired
private IpRegionService ipRegionService;
@Autowired
private com.yaoyuan.jiscuss.service.INotificationService notificationService;
@Autowired
private com.yaoyuan.jiscuss.service.IScoreService scoreService;
/**
* 首页查看主题列表(各种条件筛选,最热最新标签等)
@@ -70,8 +91,13 @@ 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);
Discussion discussion = discussionsService.findOne(id);
// 获取此主题下的评论
@@ -93,10 +119,24 @@ 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);
// Sidebar: author stats and top discussions
if (newdd.getStartUserId() != null) {
Integer authorId = newdd.getStartUserId();
long authorDiscCount = discussionsRepository.countByStartUserId(authorId);
long authorReplyCount = postsRepository.countByCreateId(authorId);
java.util.List<Discussion> authorTopDiscs = discussionsRepository
.findTopByStartUserIdOrderByLikes(authorId, PageRequest.of(0, 5));
map.put("authorDiscCount", authorDiscCount);
map.put("authorReplyCount", authorReplyCount);
map.put("authorTopDiscs", authorTopDiscs);
}
UserInfo user = getUserInfo(request);
if (user != null) {
map.put("username", user.getUsername());
@@ -146,6 +186,10 @@ public class UserPostController extends BaseController {
logger.info(">>>{}", saveDiscussion);
resultobj.put("msg", "添加主题成功");
resultobj.put("flag", true);
// Award points for creating a discussion
if (user != null) {
scoreService.addScore(user.getId(), 10);
}
return resultobj;
}
@@ -180,16 +224,35 @@ 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());
}
Post savePost = postsService.insert(post);
// Notify the discussion starter about a reply (async, ignores self-reply)
if (post.getDiscussionId() != null && user != null) {
Discussion disc = discussionsService.findOne(post.getDiscussionId());
if (disc != null && disc.getStartUserId() != null) {
notificationService.notifyReply(disc.getStartUserId(), user.getId(),
disc.getId(), disc.getTitle());
}
}
Map<String, Object> resultobj = new HashMap<>();
logger.info(">>>{}", savePost);
resultobj.put("msg", "添加评论成功");
resultobj.put("flag", true);
// Award points for posting a reply
if (user != null) {
scoreService.addScore(user.getId(), 5);
}
return resultobj;
}
@@ -6,6 +6,7 @@ import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.entity.custom.DiscussionCustom;
import com.yaoyuan.jiscuss.entity.custom.TagCustom;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.service.IDiscussionsService;
import com.yaoyuan.jiscuss.service.ITagsService;
import com.yaoyuan.jiscuss.service.IUsersService;
@@ -15,6 +16,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 +52,12 @@ public class UserSystemController extends BaseController {
@Autowired
private ITagsService tagsService;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Autowired
private DiscussionsRepository discussionsRepository;
/**
* 首页index
* @param tag
@@ -112,6 +120,8 @@ public class UserSystemController extends BaseController {
map.put("pageTotal", total);
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());
@@ -250,7 +260,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());
@@ -270,6 +280,36 @@ public class UserSystemController extends BaseController {
return "register";
}
/**
* 搜索页
*/
@GetMapping("/search")
public String search(@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "1") Integer pageNum,
HttpServletRequest request, ModelMap map) {
String keyword = q.trim();
int pageSize = 10;
int page = Math.max(pageNum - 1, 0);
Page<Discussion> resultPage = keyword.isEmpty()
? Page.empty()
: discussionsRepository.searchByKeyword(keyword,
org.springframework.data.domain.PageRequest.of(page, pageSize));
List<DiscussionCustom> results = new ArrayList<>();
if (!keyword.isEmpty()) {
setTagAndUserList(results, resultPage.getContent());
}
map.put("q", keyword);
map.put("results", results);
map.put("totalPages", resultPage.getTotalPages());
map.put("totalElements", resultPage.getTotalElements());
map.put("pageNum", pageNum);
UserInfo user = getUserInfo(request);
if (user != null) map.put("username", user.getUsername());
return "search";
}
//获取设置信息
//获取首页统计
@@ -0,0 +1,113 @@
package com.yaoyuan.jiscuss.controller.api;
import com.yaoyuan.jiscuss.controller.BaseController;
import com.yaoyuan.jiscuss.entity.Message;
import com.yaoyuan.jiscuss.entity.Notification;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.response.ApiResponse;
import com.yaoyuan.jiscuss.service.IMessageService;
import com.yaoyuan.jiscuss.service.INotificationService;
import com.yaoyuan.jiscuss.service.IUsersService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* REST API for private messages and notifications.
*/
@Tag(name = "Message API", description = "Private messages and notifications")
@RestController
@RequestMapping("/msg_api")
public class RestMsgController extends BaseController {
@Autowired
private IMessageService messageService;
@Autowired
private INotificationService notificationService;
@Autowired
private IUsersService usersService;
// ─── User card (public — accessible to any authenticated user) ────────
/** Returns basic user info for the hover card (discussionsCount, commentsCount). */
@Operation(summary = "Get user card info")
@GetMapping("/user-card/{id}")
public ApiResponse<Map<String, Object>> getUserCard(@PathVariable Integer id, HttpServletRequest request) {
UserInfo currentUser = getUserInfo(request);
if (currentUser == null) return ApiResponse.fail(401, "请先登录");
User user = usersService.findOne(id);
if (user == null) return ApiResponse.fail(404, "用户不存在");
return ApiResponse.ok(Map.of(
"id", user.getId(),
"username", user.getUsername() != null ? user.getUsername() : "",
"discussionsCount", user.getDiscussionsCount() != null ? user.getDiscussionsCount() : 0,
"commentsCount", user.getCommentsCount() != null ? user.getCommentsCount() : 0,
"level", user.getLevel() != null ? user.getLevel() : 0,
"score", user.getScore() != null ? user.getScore() : 0
));
}
// ─── Notifications ────────────────────────────────────────────────────
@Operation(summary = "Get unread notification + message counts")
@GetMapping("/unread-counts")
public ApiResponse<Map<String, Long>> unreadCounts(HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.ok(Map.of("notifications", 0L, "messages", 0L));
return ApiResponse.ok(Map.of(
"notifications", notificationService.countUnread(user.getId()),
"messages", messageService.countUnread(user.getId())
));
}
@Operation(summary = "Mark a notification as read")
@PostMapping("/notifications/{id}/read")
public ApiResponse<Void> markNotifRead(@PathVariable Integer id, HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.fail(401, "请先登录");
notificationService.markOneRead(id, user.getId());
return ApiResponse.ok(null);
}
@Operation(summary = "Mark all notifications as read")
@PostMapping("/notifications/read-all")
public ApiResponse<Void> markAllNotifRead(HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.fail(401, "请先登录");
notificationService.markAllRead(user.getId());
return ApiResponse.ok(null);
}
// ─── Messages ─────────────────────────────────────────────────────────
@Operation(summary = "Send a private message")
@PostMapping("/messages/send")
public ApiResponse<Message> sendMessage(
@RequestParam Integer receiverId,
@RequestParam String content,
HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.fail(401, "请先登录");
if (user.getId().equals(receiverId)) return ApiResponse.fail(400, "不能给自己发消息");
if (content == null || content.isBlank()) return ApiResponse.fail(400, "消息内容不能为空");
Message msg = messageService.send(user.getId(), receiverId, content.trim());
return ApiResponse.ok(msg);
}
@Operation(summary = "Get conversation with another user")
@GetMapping("/messages/conversation/{otherId}")
public ApiResponse<List<Message>> getConversation(@PathVariable Integer otherId, HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.fail(401, "请先登录");
messageService.markConversationRead(user.getId(), otherId);
return ApiResponse.ok(messageService.getConversation(user.getId(), otherId));
}
}
@@ -1,11 +1,16 @@
package com.yaoyuan.jiscuss.controller.api;
import com.yaoyuan.jiscuss.dto.UserCreateRequest;
import com.yaoyuan.jiscuss.dto.UserMapper;
import com.yaoyuan.jiscuss.dto.UserResponse;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.exception.BaseException;
import com.yaoyuan.jiscuss.response.ApiResponse;
import com.yaoyuan.jiscuss.response.ResponseCode;
import com.yaoyuan.jiscuss.service.IUsersService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -30,46 +35,51 @@ public class RestUserController {
@Autowired
private IUsersService usersService;
@Autowired
private UserMapper userMapper;
@PostMapping("/user")
@Operation(summary = "新增用户")
public User save(@RequestBody User user) {
User saveUser = usersService.insert(user);
if (saveUser != null) {
return saveUser;
} else {
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
public ApiResponse<UserResponse> save(@Valid @RequestBody UserCreateRequest request) {
User user = userMapper.fromCreateRequest(request);
User saved = usersService.insert(user);
if (saved != null) {
return ApiResponse.ok(userMapper.toResponse(saved));
}
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
}
@DeleteMapping("/user/{id}")
@Operation(summary = "删除用户")
public Boolean delete(@PathVariable Integer id) {
public ApiResponse<Boolean> delete(@PathVariable Integer id) {
usersService.remove(id);
return true;
return ApiResponse.ok(true);
}
@PutMapping("/user/{id}")
@Operation(summary = "修改用户")
public User update(@RequestBody User user, @PathVariable Integer id) {
User updateuser = usersService.update(user, id);
if (updateuser != null) {
return updateuser;
} else {
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
public ApiResponse<UserResponse> update(@RequestBody User user, @PathVariable Integer id) {
User updated = usersService.update(user, id);
if (updated != null) {
return ApiResponse.ok(userMapper.toResponse(updated));
}
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
}
@GetMapping("/user/{id}")
@Operation(summary = "获取用户")
public User getUser(@PathVariable Integer id) {
public ApiResponse<UserResponse> getUser(@PathVariable Integer id) {
User user = usersService.findOne(id);
logger.info("获取用户==>{}", user);
return user;
return ApiResponse.ok(userMapper.toResponse(user));
}
@GetMapping("/user")
@Operation(summary = "获取全部用户")
public List<User> getAllUsers() {
return usersService.getAllList();
public ApiResponse<List<UserResponse>> getAllUsers() {
List<UserResponse> list = usersService.getAllList().stream()
.map(userMapper::toResponse)
.toList();
return ApiResponse.ok(list);
}
}
@@ -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));
}
}
@@ -0,0 +1,23 @@
package com.yaoyuan.jiscuss.dto;
import lombok.Data;
import java.util.Date;
/** View model for one inbox entry (latest message in a conversation). */
@Data
public class InboxItem {
private final Integer otherId;
private final String otherUsername;
private final String lastContent;
private final Date lastTime;
private final boolean hasUnread;
public InboxItem(Integer otherId, String otherUsername, String lastContent, Date lastTime, boolean hasUnread) {
this.otherId = otherId;
this.otherUsername = otherUsername;
this.lastContent = lastContent;
this.lastTime = lastTime;
this.hasUnread = hasUnread;
}
}
@@ -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;
@@ -77,5 +80,8 @@ public class Discussion implements Serializable {
@Column(name = "create_time")
private Date createTime;
@Column(name = "view_count")
private Integer viewCount = 0;
}
@@ -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;
@@ -0,0 +1,40 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "message")
public class Message implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "sender_id", nullable = false)
private Integer senderId;
@Column(name = "receiver_id", nullable = false)
private Integer receiverId;
@Column(name = "content", columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "is_read")
private Boolean isRead = false;
@Column(name = "create_time")
private Date createTime;
/**
* Conversation identifier = "min(a,b)_max(a,b)", groups all messages
* between two users into a single conversation thread.
*/
@Column(name = "conversation_id", nullable = false)
private String conversationId;
}
@@ -0,0 +1,50 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "notification")
public class Notification implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** Receiver user ID. */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** Sender user ID (null for system notifications). */
@Column(name = "from_user_id")
private Integer fromUserId;
/** Notification type: reply / like / system. */
@Column(name = "type", nullable = false)
private String type;
@Column(name = "title")
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
/** Related entity ID (e.g., discussion or post id). */
@Column(name = "related_id")
private Integer relatedId;
/** Type of related entity: discussion / post. */
@Column(name = "related_type")
private String relatedType;
@Column(name = "is_read")
private Boolean isRead = false;
@Column(name = "create_time")
private Date createTime;
}
@@ -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;
@@ -1,5 +1,6 @@
package com.yaoyuan.jiscuss.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -39,7 +40,8 @@ public class User implements Serializable {
@Column(name = "email")
private String email;
/** BCrypt-hashed password. Column length must be >= 68 (see V2 migration). */
/** BCrypt-hashed password. Column length must be >= 68 (see V2 migration). Never serialized to JSON. */
@JsonIgnore
@Column(name = "password")
private String password;
@@ -74,4 +76,7 @@ public class User implements Serializable {
@Column(name = "level")
private Integer level;
@Column(name = "score")
private Integer score;
}
@@ -26,6 +26,7 @@ public class UserInfo implements UserDetails {
private String email;
private String gender;
private Integer level;
private Integer score;
private Integer flag;
public UserInfo() {
@@ -77,10 +78,9 @@ public class UserInfo implements UserDetails {
@Override
public String toString() {
return "UserInfo{" +
"authorities=" + authorities +
", password='" + password + '\'' +
", username='" + username + '\'' +
"username='" + username + '\'' +
", id='" + id + '\'' +
", authorities=" + authorities +
'}';
}
}
@@ -4,8 +4,13 @@ import com.yaoyuan.jiscuss.entity.Discussion;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.List;
@Repository
public interface DiscussionsRepository extends JpaRepository<Discussion, Integer> {
@@ -14,5 +19,45 @@ public interface DiscussionsRepository extends JpaRepository<Discussion, Integer
"SELECT discussion_id from discussiontag where tag_id = ?1 ) ", nativeQuery = true)
Page<Discussion> findByQuery(String tagId, Pageable pageable);
@Modifying
@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);
/** Discussions started by a given user, most recent first. */
@Query("SELECT d FROM Discussion d WHERE d.startUserId = :userId ORDER BY d.startTime DESC")
org.springframework.data.domain.Page<Discussion> findByStartUserIdOrderByStartTimeDesc(
@org.springframework.data.repository.query.Param("userId") Integer userId,
org.springframework.data.domain.Pageable pageable);
long countByStartUserId(Integer startUserId);
/** Author's top discussions ordered by likeCount (for sidebar). */
@Query("SELECT d FROM Discussion d WHERE d.startUserId = :userId ORDER BY COALESCE(d.likeCount, 0) DESC, d.startTime DESC")
List<Discussion> findTopByStartUserIdOrderByLikes(
@org.springframework.data.repository.query.Param("userId") Integer userId,
Pageable pageable);
/** Full-text search by title or content. */
@Query("SELECT d FROM Discussion d WHERE d.title LIKE %:q% OR d.content LIKE %:q% ORDER BY d.startTime DESC")
Page<Discussion> searchByKeyword(@org.springframework.data.repository.query.Param("q") String q, Pageable pageable);
}
@@ -0,0 +1,25 @@
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);
/** Discussions liked by a user (action = 'like'). */
@Query("SELECT lc.discussionId FROM LikeCollect lc WHERE lc.userId = :userId AND lc.type = 'discussion' AND lc.action = 'like'")
java.util.List<Integer> findLikedDiscussionIdsByUser(@Param("userId") Integer userId);
}
@@ -0,0 +1,31 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.Message;
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;
@Repository
public interface MessageRepository extends JpaRepository<Message, Integer> {
/** Latest messages in a conversation, most recent first. */
List<Message> findByConversationIdOrderByCreateTimeDesc(String conversationId);
/** All messages sent by user. */
List<Message> findBySenderIdOrderByCreateTimeDesc(Integer senderId);
/** All messages received by user. */
List<Message> findByReceiverIdOrderByCreateTimeDesc(Integer receiverId);
long countByReceiverIdAndIsReadFalse(Integer receiverId);
@Modifying
@Transactional
@Query("UPDATE Message m SET m.isRead = true WHERE m.conversationId = :convId AND m.receiverId = :userId")
void markConversationReadForUser(@Param("convId") String convId, @Param("userId") Integer userId);
}
@@ -0,0 +1,29 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.Notification;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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;
@Repository
public interface NotificationRepository extends JpaRepository<Notification, Integer> {
Page<Notification> findByUserIdOrderByCreateTimeDesc(Integer userId, Pageable pageable);
long countByUserIdAndIsReadFalse(Integer userId);
@Modifying
@Transactional
@Query("UPDATE Notification n SET n.isRead = true WHERE n.userId = :userId")
void markAllReadByUserId(@Param("userId") Integer userId);
@Modifying
@Transactional
@Query("UPDATE Notification n SET n.isRead = true WHERE n.id = :id AND n.userId = :userId")
void markReadById(@Param("id") Integer id, @Param("userId") Integer userId);
}
@@ -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" +
@@ -55,4 +89,11 @@ public interface PostsRepository extends JpaRepository<Post, Integer> {
//
// @Query("from Post where parentId is not null and discussionId = :dId")
// List<Post> findAllByDIdAndparentIdNotNull(@Param("dId")Integer dId);
/** Posts (replies) created by a given user, most recent first. */
@Query("SELECT p FROM Post p WHERE p.createId = :userId ORDER BY p.createTime DESC")
org.springframework.data.domain.Page<Post> findByCreateIdOrderByCreateTimeDesc(
@Param("userId") Integer userId, org.springframework.data.domain.Pageable pageable);
long countByCreateId(Integer createId);
}
@@ -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,31 @@ 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);
@Modifying
@Transactional
@Query("UPDATE User u SET u.score = GREATEST(0, COALESCE(u.score, 0) + :delta), " +
"u.level = CASE " +
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 2000 THEN 5 " +
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 1000 THEN 4 " +
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 500 THEN 3 " +
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 200 THEN 2 " +
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 50 THEN 1 " +
" ELSE 0 END " +
"WHERE u.id = :id")
void addScore(@Param("id") Integer id, @Param("delta") int delta);
}
@@ -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);
}
@@ -14,4 +14,6 @@ public interface IDiscussionsService {
Discussion findOne(Integer id);
Page<Discussion> queryAllDiscussionsList(Discussion discussion, int pageNumNew, int pageSiz, String tag, String type);
void incrementViewCount(Integer id);
}
@@ -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);
}
@@ -0,0 +1,23 @@
package com.yaoyuan.jiscuss.service;
import com.yaoyuan.jiscuss.entity.Message;
import java.util.List;
public interface IMessageService {
/** Send a message from sender to receiver. */
Message send(Integer senderId, Integer receiverId, String content);
/** All messages in a conversation (ordered oldest→newest). */
List<Message> getConversation(Integer userId, Integer otherId);
/** Inbox: latest message per conversation, sorted by latest activity (max 50). */
List<Message> getInbox(Integer userId);
/** Unread message count for a user. */
long countUnread(Integer userId);
/** Mark an entire conversation as read for the current user. */
void markConversationRead(Integer userId, Integer otherId);
}
@@ -0,0 +1,27 @@
package com.yaoyuan.jiscuss.service;
import com.yaoyuan.jiscuss.entity.Notification;
import org.springframework.data.domain.Page;
public interface INotificationService {
/** Create a notification for a user. */
Notification create(Integer toUserId, Integer fromUserId, String type, String title, String content,
Integer relatedId, String relatedType);
/** Convenience: create a "reply" notification. */
void notifyReply(Integer toUserId, Integer fromUserId, Integer discussionId, String discussionTitle);
/** Convenience: create a "like" notification for a post. */
void notifyLike(Integer toUserId, Integer fromUserId, Integer postId, Integer discussionId);
/** Unread notification count for a user. */
long countUnread(Integer userId);
/** Paged notification list for a user. */
Page<Notification> listByUser(Integer userId, int page, int size);
void markAllRead(Integer userId);
void markOneRead(Integer notificationId, Integer userId);
}
@@ -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);
}
@@ -0,0 +1,42 @@
package com.yaoyuan.jiscuss.service;
/**
* Service for awarding activity points and recomputing user levels.
*
* <p>Level thresholds (score → level):
* <ul>
* <li>0 49 → 0 新手</li>
* <li>50 199 → 1 学徒</li>
* <li>200 499 → 2 熟手</li>
* <li>500 999 → 3 达人</li>
* <li>1000 1999 → 4 专家</li>
* <li>2000+ → 5 大神</li>
* </ul>
*/
public interface IScoreService {
/** Award (or deduct) {@code delta} points to the user and refresh their level. */
void addScore(Integer userId, int delta);
/** Compute the level (0-5) corresponding to a given score. */
static int computeLevel(int score) {
if (score >= 2000) return 5;
if (score >= 1000) return 4;
if (score >= 500) return 3;
if (score >= 200) return 2;
if (score >= 50) return 1;
return 0;
}
/** Human-readable name for a level (0-5). */
static String levelName(int level) {
return switch (level) {
case 1 -> "学徒";
case 2 -> "熟手";
case 3 -> "达人";
case 4 -> "专家";
case 5 -> "大神";
default -> "新手";
};
}
}
@@ -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,14 +33,18 @@ 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)
@Override
public Discussion findOne(Integer id) {
// getReferenceById replaces removed JpaRepository.getOne()
return discussionsRepository.getReferenceById(id);
return discussionsRepository.findById(id).orElse(null);
}
@Transactional(readOnly = true)
@@ -55,4 +63,10 @@ public class DiscussionsServiceImpl implements IDiscussionsService {
return discussionsRepository.findAll(example, pageable);
}
}
@Transactional
@Override
public void incrementViewCount(Integer id) {
discussionsRepository.incrementViewCount(id);
}
}
@@ -0,0 +1,165 @@
package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.LikeCollect;
import com.yaoyuan.jiscuss.entity.Post;
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 com.yaoyuan.jiscuss.service.INotificationService;
import com.yaoyuan.jiscuss.entity.Discussion;
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;
@Autowired
private INotificationService notificationService;
@Autowired
private com.yaoyuan.jiscuss.service.IScoreService scoreService;
@Autowired
private com.yaoyuan.jiscuss.repository.UsersRepository usersRepository;
@Transactional
@Override
public String voteDiscussion(Integer userId, Integer discussionId, String action) {
// Look up discussion author once for score updates
com.yaoyuan.jiscuss.entity.Discussion disc = discussionsRepository.findById(discussionId).orElse(null);
Integer authorId = (disc != null) ? disc.getStartUserId() : null;
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);
// Reverse score for the author (only likes affect score)
if ("like".equals(action) && authorId != null && !authorId.equals(userId)) {
scoreService.addScore(authorId, -2);
}
return "none";
} else {
// Switch action: undo old, apply new
adjustDiscussion(discussionId, vote.getAction(), -1);
vote.setAction(action);
likeCollectRepository.save(vote);
adjustDiscussion(discussionId, action, +1);
// Switched from like→dislike: deduct; dislike→like: award
if (authorId != null && !authorId.equals(userId)) {
if ("like".equals(action)) {
scoreService.addScore(authorId, +2);
} else {
scoreService.addScore(authorId, -2);
}
}
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);
// Award score to discussion author on new like
if ("like".equals(action) && authorId != null && !authorId.equals(userId)) {
scoreService.addScore(authorId, +2);
}
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);
// Reverse score on unlike
if ("like".equals(action)) {
Post post = postsRepository.findById(postId).orElse(null);
if (post != null && post.getCreateId() != null && !post.getCreateId().equals(userId)) {
scoreService.addScore(post.getCreateId(), -2);
}
}
return "none";
} else {
adjustPost(postId, vote.getAction(), -1);
vote.setAction(action);
likeCollectRepository.save(vote);
adjustPost(postId, action, +1);
// Switched vote direction: adjust score accordingly
Post post = postsRepository.findById(postId).orElse(null);
if (post != null && post.getCreateId() != null && !post.getCreateId().equals(userId)) {
if ("like".equals(action)) {
scoreService.addScore(post.getCreateId(), +2);
} else {
scoreService.addScore(post.getCreateId(), -2);
}
}
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);
// Award score to post author on new like; also send notification
if ("like".equals(action)) {
Post post = postsRepository.findById(postId).orElse(null);
if (post != null && post.getCreateId() != null) {
notificationService.notifyLike(post.getCreateId(), userId, postId, post.getDiscussionId());
if (!post.getCreateId().equals(userId)) {
scoreService.addScore(post.getCreateId(), +2);
}
}
}
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);
}
}
}
@@ -0,0 +1,85 @@
package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.Message;
import com.yaoyuan.jiscuss.repository.MessageRepository;
import com.yaoyuan.jiscuss.service.IMessageService;
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.List;
@Service
public class MessageServiceImpl implements IMessageService {
@Autowired
private MessageRepository messageRepository;
private String buildConversationId(Integer a, Integer b) {
return Math.min(a, b) + "_" + Math.max(a, b);
}
@Transactional
@Override
public Message send(Integer senderId, Integer receiverId, String content) {
Message msg = new Message();
msg.setSenderId(senderId);
msg.setReceiverId(receiverId);
msg.setContent(content);
msg.setIsRead(false);
msg.setCreateTime(new Date());
msg.setConversationId(buildConversationId(senderId, receiverId));
return messageRepository.save(msg);
}
@Transactional(readOnly = true)
@Override
public List<Message> getConversation(Integer userId, Integer otherId) {
String convId = buildConversationId(userId, otherId);
List<Message> msgs = messageRepository.findByConversationIdOrderByCreateTimeDesc(convId);
// reverse to get oldest-first order
java.util.Collections.reverse(msgs);
return msgs;
}
@Transactional(readOnly = true)
@Override
public List<Message> getInbox(Integer userId) {
// Fetch sent and received separately, merge, then deduplicate by conversationId
List<Message> sent = messageRepository.findBySenderIdOrderByCreateTimeDesc(userId);
List<Message> received = messageRepository.findByReceiverIdOrderByCreateTimeDesc(userId);
// Merge both lists, sort by createTime descending
java.util.List<Message> all = new java.util.ArrayList<>();
all.addAll(sent);
all.addAll(received);
all.sort((a, b) -> {
if (a.getCreateTime() == null) return 1;
if (b.getCreateTime() == null) return -1;
return b.getCreateTime().compareTo(a.getCreateTime());
});
// Keep only the latest message per conversation
java.util.LinkedHashMap<String, Message> seen = new java.util.LinkedHashMap<>();
for (Message m : all) {
String cid = (m.getConversationId() != null && !m.getConversationId().isEmpty())
? m.getConversationId()
: Math.min(m.getSenderId(), m.getReceiverId()) + "_" + Math.max(m.getSenderId(), m.getReceiverId());
seen.putIfAbsent(cid, m);
}
return new java.util.ArrayList<>(seen.values());
}
@Transactional(readOnly = true)
@Override
public long countUnread(Integer userId) {
return messageRepository.countByReceiverIdAndIsReadFalse(userId);
}
@Transactional
@Override
public void markConversationRead(Integer userId, Integer otherId) {
messageRepository.markConversationReadForUser(buildConversationId(userId, otherId), userId);
}
}
@@ -0,0 +1,83 @@
package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.Notification;
import com.yaoyuan.jiscuss.repository.NotificationRepository;
import com.yaoyuan.jiscuss.service.INotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
@Service
public class NotificationServiceImpl implements INotificationService {
@Autowired
private NotificationRepository notificationRepository;
@Transactional
@Override
public Notification create(Integer toUserId, Integer fromUserId, String type, String title,
String content, Integer relatedId, String relatedType) {
// Do not notify yourself
if (toUserId != null && toUserId.equals(fromUserId)) {
return null;
}
Notification n = new Notification();
n.setUserId(toUserId);
n.setFromUserId(fromUserId);
n.setType(type);
n.setTitle(title);
n.setContent(content);
n.setRelatedId(relatedId);
n.setRelatedType(relatedType);
n.setIsRead(false);
n.setCreateTime(new Date());
return notificationRepository.save(n);
}
@Async
@Override
public void notifyReply(Integer toUserId, Integer fromUserId, Integer discussionId, String discussionTitle) {
create(toUserId, fromUserId, "reply",
"有人回复了你的帖子",
"在话题《" + discussionTitle + "》中回复了你",
discussionId, "discussion");
}
@Async
@Override
public void notifyLike(Integer toUserId, Integer fromUserId, Integer postId, Integer discussionId) {
create(toUserId, fromUserId, "like",
"有人赞了你的回复",
null, postId, "post");
}
@Transactional(readOnly = true)
@Override
public long countUnread(Integer userId) {
return notificationRepository.countByUserIdAndIsReadFalse(userId);
}
@Transactional(readOnly = true)
@Override
public Page<Notification> listByUser(Integer userId, int page, int size) {
return notificationRepository.findByUserIdOrderByCreateTimeDesc(
userId, PageRequest.of(page, size));
}
@Transactional
@Override
public void markAllRead(Integer userId) {
notificationRepository.markAllReadByUserId(userId);
}
@Transactional
@Override
public void markOneRead(Integer notificationId, Integer userId) {
notificationRepository.markReadById(notificationId, userId);
}
}
@@ -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;
}
}
@@ -0,0 +1,23 @@
package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.repository.UsersRepository;
import com.yaoyuan.jiscuss.service.IScoreService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ScoreServiceImpl implements IScoreService {
@Autowired
private UsersRepository usersRepository;
@Override
@Transactional
@CacheEvict(value = "user", key = "#userId")
public void addScore(Integer userId, int delta) {
if (userId == null || delta == 0) return;
usersRepository.addScore(userId, delta);
}
}
@@ -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,23 @@
-- V10: Add score column for the points/level system.
-- score: cumulative activity points (default 0).
-- level is recomputed by ScoreServiceImpl whenever score changes.
ALTER TABLE user ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
-- Back-fill an initial score for existing users based on their activity counts.
-- Formula: discussions_count*10 + comments_count*5 (mirrors live award rates)
UPDATE user
SET score = COALESCE(discussions_count, 0) * 10 + COALESCE(comments_count, 0) * 5;
-- Recompute level for ALL users based on back-filled score.
-- Thresholds: 0=新手(<50), 1=学徒(50-199), 2=熟手(200-499),
-- 3=达人(500-999), 4=专家(1000-1999), 5=大神(>=2000)
-- Note: admin access is now RBAC-based; level is purely cosmetic.
UPDATE user SET level =
CASE
WHEN score >= 2000 THEN 5
WHEN score >= 1000 THEN 4
WHEN score >= 500 THEN 3
WHEN score >= 200 THEN 2
WHEN score >= 50 THEN 1
ELSE 0
END;
@@ -0,0 +1,26 @@
-- V6: Add indexes for high-frequency query columns.
-- All indexes use IF NOT EXISTS for idempotent migrations.
-- post table: most reads filter/sort by discussion_id, parent_id, create_time
CREATE INDEX IF NOT EXISTS idx_post_discussion_id ON post(discussion_id);
CREATE INDEX IF NOT EXISTS idx_post_parent_id ON post(parent_id);
CREATE INDEX IF NOT EXISTS idx_post_create_time ON post(create_time);
CREATE INDEX IF NOT EXISTS idx_post_create_id ON post(create_id);
-- discussion table: sorted by id/create_time/start_time/like_count; joined on start_user_id/last_user_id
CREATE INDEX IF NOT EXISTS idx_discussion_create_time ON discussion(create_time);
CREATE INDEX IF NOT EXISTS idx_discussion_start_time ON discussion(start_time);
CREATE INDEX IF NOT EXISTS idx_discussion_like_count ON discussion(like_count);
CREATE INDEX IF NOT EXISTS idx_discussion_start_user_id ON discussion(start_user_id);
CREATE INDEX IF NOT EXISTS idx_discussion_last_user_id ON discussion(last_user_id);
-- discussiontag table: used in tag-filter queries and cascade deletes
CREATE INDEX IF NOT EXISTS idx_discussiontag_discussion_id ON discussiontag(discussion_id);
CREATE INDEX IF NOT EXISTS idx_discussiontag_tag_id ON discussiontag(tag_id);
-- user table: login and username-lookup path
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
-- likecollect table: user activity queries
CREATE INDEX IF NOT EXISTS idx_likecollect_user_id ON likecollect(user_id);
CREATE INDEX IF NOT EXISTS idx_likecollect_discussion_id ON likecollect(discussion_id);
@@ -0,0 +1,5 @@
-- V7: Add view_count to discussion table.
-- Tracks how many times a discussion has been viewed.
ALTER TABLE discussion ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_discussion_view_count ON discussion(view_count);
@@ -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);
@@ -0,0 +1,33 @@
-- V9: Add notification and private message support
-- Notification table: system/reply/like alerts to a user
CREATE TABLE IF NOT EXISTS notification (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL, -- receiver
from_user_id INT, -- sender (NULL for system notifications)
type VARCHAR(50) NOT NULL, -- 'reply', 'like', 'system'
title VARCHAR(200),
content TEXT,
related_id INT, -- e.g. discussion_id or post_id
related_type VARCHAR(50), -- 'discussion', 'post'
is_read BOOLEAN DEFAULT FALSE,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notification_user ON notification(user_id);
CREATE INDEX IF NOT EXISTS idx_notification_user_unread ON notification(user_id, is_read);
-- Message table: private messages between users
CREATE TABLE IF NOT EXISTS message (
id INT PRIMARY KEY AUTO_INCREMENT,
sender_id INT NOT NULL,
receiver_id INT NOT NULL,
content TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
conversation_id VARCHAR(50) NOT NULL -- format: min_max of user IDs
);
CREATE INDEX IF NOT EXISTS idx_message_receiver ON message(receiver_id);
CREATE INDEX IF NOT EXISTS idx_message_conversation ON message(conversation_id, create_time);
CREATE INDEX IF NOT EXISTS idx_message_receiver_unread ON message(receiver_id, is_read);
File diff suppressed because one or more lines are too long
+1
View File
@@ -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)}}
+1
View File
@@ -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,73 @@
/* ── 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);
}
/* ── Global reset ─────────────────────────────────────────────────────────── */
html, body { margin: 0; padding: 0; }
body { background-color: var(--bg-page) !important; color: var(--text-primary); }
/* ── Sticky header (CSS sticky — works on all pages) ─────────────────────── */
#menu.ui.menu {
position: sticky;
top: 0;
z-index: 999;
margin-top: 0 !important;
margin-bottom: 0 !important;
box-shadow: 0 2px 6px rgba(0,0,0,.08);
}
/* ── Page content spacing ─────────────────────────────────────────────────── */
#container { margin-top: 1.5rem !important; }
/* ── Back-to-top button ───────────────────────────────────────────────────── */
#backToTop {
position: fixed;
bottom: 32px;
right: 32px;
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 1.2em;
box-shadow: 0 2px 8px rgba(0,0,0,.2);
z-index: 1000;
transition: opacity .2s;
}
#backToTop:hover { opacity: .85; }
/* 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;
}
@@ -9,46 +9,146 @@
<title>${title}</title>
<#include "admin/admin-commjs.ftl"/>
<style>
body { background: #f6f7fb; }
.admin-header { background: #1f2937; color: #fff; padding: 12px 18px; }
.admin-grid { display: grid; grid-template-columns: 230px 1fr; gap: 16px; padding: 16px; }
.admin-sidebar { background: #fff; border-radius: 8px; padding: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.admin-main { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.admin-sidebar a.item.active { background: #1d4ed8 !important; color: #fff !important; border-radius: 6px; }
.metric-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }
.small-muted { color: #6b7280; font-size: 12px; }
@media (max-width: 900px) {
.admin-grid { grid-template-columns: 1fr; }
body { background: #f6f7fb; margin: 0; font-family: 'Lato', 'Helvetica Neue', Arial, sans-serif; }
/* ── Top navigation bar (matches main site style) ── */
.admin-topbar {
background: #1b1c1d;
color: #fff;
padding: 0 20px;
height: 50px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0,0,0,.2);
position: sticky;
top: 0;
z-index: 100;
}
.admin-topbar .brand { font-size: 1.05em; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.admin-topbar .brand img { height: 24px; }
.admin-topbar .nav-right { display: flex; align-items: center; gap: 18px; font-size: .88em; }
.admin-topbar .nav-right a { color: #93c5fd; text-decoration: none; }
.admin-topbar .nav-right a:hover { color: #fff; }
.admin-topbar .nav-right .admin-name { color: #d1d5db; }
/* ── Layout ── */
.admin-layout { display: grid; grid-template-columns: 220px 1fr; min-height: calc(100vh - 50px - 60px); gap: 0; }
@media (max-width: 900px) { .admin-layout { grid-template-columns: 1fr; } }
/* ── Sidebar ── */
.admin-sidebar {
background: #fff;
border-right: 1px solid #e5e7eb;
padding: 12px 8px;
}
.admin-sidebar .section-label {
font-size: .72em; font-weight: 700; color: #9ca3af;
text-transform: uppercase; letter-spacing: .06em;
padding: 8px 12px 4px;
}
.admin-sidebar a.item {
display: flex; align-items: center; gap: 8px;
padding: 9px 14px; border-radius: 7px;
color: #374151; font-size: .91em;
text-decoration: none; margin: 1px 0;
transition: background .15s, color .15s;
}
.admin-sidebar a.item:hover { background: #f3f4f6; color: #111; }
.admin-sidebar a.item.active { background: #1d4ed8; color: #fff; font-weight: 600; }
.admin-sidebar a.item.active:hover { background: #1e40af; }
.admin-sidebar .divider { border-top: 1px solid #e5e7eb; margin: 8px 0; }
/* ── Main content ── */
.admin-main { padding: 24px; }
.admin-main .page-card {
background: #fff; border-radius: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,.07);
padding: 24px;
}
/* ── Footer ── */
.admin-footer {
background: #fff;
border-top: 1px solid #e5e7eb;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: .82em;
color: #9ca3af;
}
.admin-footer a { color: #6b7280; text-decoration: none; }
.admin-footer a:hover { color: #2185d0; }
</style>
</head>
<body>
<div class="admin-header">
<div class="ui grid">
<div class="ten wide column">Jiscuss 后台管理</div>
<div class="six wide right aligned column">当前管理员: ${adminName} | <a style="color:#93c5fd" href="/swagger-ui.html" target="_blank">接口文档</a> | <a style="color:#93c5fd" href="/">前台首页</a></div>
<div class="admin-topbar">
<div class="brand">
<img src="/static/assets/images/logo.png" alt="logo">
Jiscuss 后台管理
</div>
<div class="nav-right">
<span class="admin-name"><i class="user circle icon"></i> ${adminName}</span>
<a href="/swagger-ui.html" target="_blank"><i class="book icon"></i> 接口文档</a>
<a href="/"><i class="home icon"></i> 前台首页</a>
</div>
</div>
<div class="admin-grid">
<div class="admin-layout">
<div class="admin-sidebar">
<div class="ui vertical fluid menu">
<a class="item <#if active=='home'>active</#if>" href="/admin/home">总览与运维</a>
<a class="item <#if active=='users'>active</#if>" href="/admin/users">用户与角色</a>
<a class="item <#if active=='discussions'>active</#if>" href="/admin/discussions">文章管理</a>
<a class="item <#if active=='posts'>active</#if>" href="/admin/posts">评论管理</a>
<a class="item <#if active=='upgradeLogs'>active</#if>" href="/admin/upgrade-logs">版本升级日志</a>
<a class="item <#if active=='auditLogs'>active</#if>" href="/admin/audit-logs">操作审计日志</a>
<div class="item small-muted">预留扩展</div>
<a class="item <#if active=='plugins'>active</#if>" href="/admin/plugins">插件管理(预留)</a>
<a class="item <#if active=='themes'>active</#if>" href="/admin/themes">主题管理(预留)</a>
</div>
<div class="section-label">概览</div>
<a class="item <#if active=='home'>active</#if>" href="/admin/home">
<i class="tachometer alternate icon"></i>总览与运维
</a>
<div class="divider"></div>
<div class="section-label">内容管理</div>
<a class="item <#if active=='discussions'>active</#if>" href="/admin/discussions">
<i class="list alternate outline icon"></i>文章管理
</a>
<a class="item <#if active=='posts'>active</#if>" href="/admin/posts">
<i class="comments outline icon"></i>评论管理
</a>
<div class="divider"></div>
<div class="section-label">用户管理</div>
<a class="item <#if active=='users'>active</#if>" href="/admin/users">
<i class="users icon"></i>用户与角色
</a>
<a class="item <#if active=='scores'>active</#if>" href="/admin/scores">
<i class="trophy icon"></i>积分管理
</a>
<div class="divider"></div>
<div class="section-label">系统</div>
<a class="item <#if active=='upgradeLogs'>active</#if>" href="/admin/upgrade-logs">
<i class="history icon"></i>升级日志
</a>
<a class="item <#if active=='auditLogs'>active</#if>" href="/admin/audit-logs">
<i class="shield alternate icon"></i>操作审计
</a>
<div class="divider"></div>
<div class="section-label">预留</div>
<a class="item <#if active=='plugins'>active</#if>" href="/admin/plugins">
<i class="plug icon"></i>插件管理
</a>
<a class="item <#if active=='themes'>active</#if>" href="/admin/themes">
<i class="paint brush icon"></i>主题管理
</a>
</div>
<div class="admin-main">
<#nested>
<div class="page-card">
<#nested>
</div>
</div>
</div>
<div class="admin-footer">
<span>Jiscuss 后台管理系统 &copy; 2019-2026</span>
<span>Powered by <strong>Jiscuss V2.0</strong> &nbsp;|&nbsp; <a href="/">返回前台</a></span>
</div>
</body>
</html>
</#macro>
+56 -31
View File
@@ -1,50 +1,75 @@
<#import "admin/admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 总览" active=active adminName=adminName>
<h2 class="ui header">系统总览</h2>
<h2 class="ui header" style="margin-bottom:1.2em;"><i class="tachometer alternate icon"></i>系统总览</h2>
<div class="ui four stackable cards">
<div class="card"><div class="content"><div class="header">用户总数</div><div class="description">${userCount}</div></div></div>
<div class="card"><div class="content"><div class="header">文章总数</div><div class="description">${discussionCount}</div></div></div>
<div class="card"><div class="content"><div class="header">评论总数</div><div class="description">${postCount}</div></div></div>
<div class="card"><div class="content"><div class="header">角色总数</div><div class="description">${roleCount}</div></div></div>
<div class="ui four stackable cards" style="margin-bottom:1.5em;">
<div class="card" style="border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.08);">
<div class="content">
<div class="header" style="font-size:1em; color:#6b7280;">用户总数</div>
<div class="description" style="font-size:2em; font-weight:700; color:#2185d0;">${userCount}</div>
</div>
</div>
<div class="card" style="border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.08);">
<div class="content">
<div class="header" style="font-size:1em; color:#6b7280;">文章总数</div>
<div class="description" style="font-size:2em; font-weight:700; color:#21ba45;">${discussionCount}</div>
</div>
</div>
<div class="card" style="border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.08);">
<div class="content">
<div class="header" style="font-size:1em; color:#6b7280;">评论总数</div>
<div class="description" style="font-size:2em; font-weight:700; color:#f2711c;">${postCount}</div>
</div>
</div>
<div class="card" style="border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.08);">
<div class="content">
<div class="header" style="font-size:1em; color:#6b7280;">角色总数</div>
<div class="description" style="font-size:2em; font-weight:700; color:#a333c8;">${roleCount}</div>
</div>
</div>
</div>
<h3 class="ui dividing header">服务器与运行时</h3>
<div class="ui two stackable cards">
<div class="card">
<h3 class="ui dividing header"><i class="server icon"></i>服务器与运行时</h3>
<div class="ui two stackable cards" style="margin-bottom:1.5em;">
<div class="card" style="border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.08);">
<div class="content">
<div class="header">CPU / 内存</div>
<div class="header" style="font-size:1em; color:#374151; margin-bottom:.6em;"><i class="microchip icon"></i>CPU / 内存</div>
<div class="description">
<p>CPU 负载: ${sys.cpuLoad}</p>
<p>JVM 已用内存: ${sys.usedMb} MB / ${sys.totalMb} MB</p>
<p>JVM 最大内存: ${sys.maxMb} MB</p>
<p>Heap 已用: ${sys.heapUsedMb} MB</p>
<p>应用运行时长: ${sys.uptimeMs} ms</p>
<p><span style="color:#888;">CPU 负载</span>&nbsp;&nbsp;<b>${sys.cpuLoad}</b></p>
<p><span style="color:#888;">JVM 已用</span>&nbsp;&nbsp;<b>${sys.usedMb} MB</b> / ${sys.totalMb} MB</p>
<p><span style="color:#888;">JVM 最大</span>&nbsp;&nbsp;<b>${sys.maxMb} MB</b></p>
<p><span style="color:#888;">Heap 已用</span>&nbsp;&nbsp;<b>${sys.heapUsedMb} MB</b></p>
<p><span style="color:#888;">运行时长</span>&nbsp;&nbsp;<b>${sys.uptimeMs} ms</b></p>
</div>
</div>
</div>
<div class="card">
<div class="card" style="border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,.08);">
<div class="content">
<div class="header">关键依赖版本</div>
<div class="header" style="font-size:1em; color:#374151; margin-bottom:.6em;"><i class="code branch icon"></i>关键依赖版本</div>
<div class="description">
<p>Spring Boot: ${deps.springBoot}</p>
<p>Java: ${deps.java}</p>
<p>Spring: ${deps.spring}</p>
<p>Hibernate: ${deps.hibernate}</p>
<p>Flyway: ${deps.flyway}</p>
<p>Druid: ${deps.druid}</p>
<p>Ehcache: ${deps.ehcache}</p>
<p>SpringDoc: ${deps.springdoc}</p>
<table style="width:100%; border-collapse:collapse; font-size:.9em;">
<tr><td style="color:#888; padding:2px 0;">Spring Boot</td><td><b>${deps.springBoot}</b></td></tr>
<tr><td style="color:#888; padding:2px 0;">Java</td><td><b>${deps.java}</b></td></tr>
<tr><td style="color:#888; padding:2px 0;">Spring</td><td><b>${deps.spring}</b></td></tr>
<tr><td style="color:#888; padding:2px 0;">Hibernate</td><td><b>${deps.hibernate}</b></td></tr>
<tr><td style="color:#888; padding:2px 0;">Flyway</td><td><b>${deps.flyway}</b></td></tr>
<tr><td style="color:#888; padding:2px 0;">Druid</td><td><b>${deps.druid}</b></td></tr>
<tr><td style="color:#888; padding:2px 0;">Ehcache</td><td><b>${deps.ehcache}</b></td></tr>
<tr><td style="color:#888; padding:2px 0;">SpringDoc</td><td><b>${deps.springdoc}</b></td></tr>
</table>
</div>
</div>
</div>
</div>
<h3 class="ui dividing header">快捷入口</h3>
<div class="ui buttons">
<a class="ui primary button" href="/admin/users">用户与角色</a>
<a class="ui button" href="/admin/discussions">文章管理</a>
<a class="ui button" href="/admin/posts">评论管理</a>
<a class="ui button" href="/swagger-ui.html" target="_blank">接口文档</a>
<h3 class="ui dividing header"><i class="lightning icon"></i>快捷入口</h3>
<div style="display:flex; flex-wrap:wrap; gap:10px;">
<a class="ui primary button" href="/admin/users"><i class="users icon"></i>用户与角色</a>
<a class="ui teal button" href="/admin/scores"><i class="trophy icon"></i>积分管理</a>
<a class="ui button" href="/admin/discussions"><i class="list alternate outline icon"></i>文章管理</a>
<a class="ui button" href="/admin/posts"><i class="comments outline icon"></i>评论管理</a>
<a class="ui button" href="/admin/audit-logs"><i class="shield alternate icon"></i>审计日志</a>
<a class="ui button" href="/swagger-ui.html" target="_blank"><i class="book icon"></i>接口文档</a>
</div>
</@shell.page>
@@ -0,0 +1,75 @@
<#import "admin/admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 积分管理" active=active adminName=adminName>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.2em;">
<h2 class="ui header" style="margin:0;"><i class="trophy icon"></i>积分管理</h2>
<span style="color:#888; font-size:.9em;">共 ${users?size} 位用户</span>
</div>
<#assign lvlNames = ["新手","学徒","熟手","达人","专家","大神"]>
<#assign lvlColors = ["#95a5a6","#27ae60","#2980b9","#8e44ad","#e67e22","#e74c3c"]>
<div class="ui segments" style="margin-bottom:1.5em; background:#f8f9ff; border-radius:8px; padding:12px 16px;">
<div style="font-size:.9em; color:#555;">
<b>等级阈值:</b>
<#list 0..5 as i>
<span style="display:inline-block; margin-right:10px;">
<span style="display:inline-block; padding:1px 8px; border-radius:10px;
background:${lvlColors[i]}; color:#fff; font-size:.82em; font-weight:bold;">
Lv.${i} ${lvlNames[i]}
</span>
<span style="color:#888;">
<#if i==0>&lt;50<#elseif i==1>50199<#elseif i==2>200499
<#elseif i==3>500999<#elseif i==4>10001999<#else>≥2000</#if>
</span>
</span>
</#list>
</div>
</div>
<table class="ui celled striped table" style="font-size:.93em;">
<thead>
<tr>
<th style="width:50px;">ID</th>
<th>用户名</th>
<th>真实姓名</th>
<th style="width:90px; text-align:center;">等级</th>
<th style="width:80px; text-align:center;">积分</th>
<th style="width:70px; text-align:center;">发帖</th>
<th style="width:70px; text-align:center;">回复</th>
<th style="width:180px; text-align:center;">手动调整积分</th>
</tr>
</thead>
<tbody>
<#list users as u>
<#assign lvl = (u.level)!0>
<#assign lvlIdx = lvl?int>
<#if lvlIdx < 0><#assign lvlIdx = 0></#if>
<#if lvlIdx > 5><#assign lvlIdx = 5></#if>
<tr>
<td>${u.id}</td>
<td><a href="/profile?uid=${u.id}" target="_blank">${u.username!}</a></td>
<td style="color:#666;">${u.realname!'-'}</td>
<td style="text-align:center;">
<span style="display:inline-block; padding:2px 10px; border-radius:12px;
background:${lvlColors[lvlIdx]}; color:#fff; font-size:.82em; font-weight:bold;">
Lv.${lvl} ${lvlNames[lvlIdx]}
</span>
</td>
<td style="text-align:center; font-weight:bold; color:#2185d0;">${(u.score)!0}</td>
<td style="text-align:center;">${(u.discussionsCount)!0}</td>
<td style="text-align:center;">${(u.commentsCount)!0}</td>
<td style="text-align:center;">
<form method="post" action="/admin/scores/${u.id}/adjust"
style="display:flex; align-items:center; gap:4px; justify-content:center;">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<input type="number" name="delta" value="0"
style="width:70px; text-align:center; border:1px solid #ddd; border-radius:4px; padding:3px 6px;"
min="-9999" max="9999">
<button type="submit" class="ui mini primary button" style="margin:0;">确定</button>
</form>
</td>
</tr>
</#list>
</tbody>
</table>
</@shell.page>
+10 -15
View File
@@ -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>
+10 -7
View File
@@ -7,8 +7,8 @@
<div class="three wide column">
<h4 class="ui header">关于遥远</h4>
<div class="ui link list">
<a href="http://yaoyuan.io" class="item">Yaoyuan.io</a>
<a href="http://cyy.im" class="item">CYY.IM</a>
<a href="http://chuyaoyuan.com" class="item">Chuyaoyuan</a>
</div>
</div>
@@ -29,20 +29,23 @@
</div>
</div>
<div class="seven wide column">
<h4 class="ui header">这里是页脚预留信息</h4>
<p>This is about Jiscuus Page
.</p>
<h4 class="ui header">关于 Jiscuss</h4>
<p>一个简单、易用的 Java 开源论坛,基于 Spring Boot + Semantic UI 构建。</p>
</div>
</div>
<div class="ui section divider"></div>
<img src="static/assets/images/logo.png" class="ui centered mini image">
@ 2019-2020 &nbsp;
<div class="ui horizontal small divided link list">
<img src="/static/assets/images/logo.png" class="ui centered mini image">
&copy; 2019-2026 &nbsp;
<div class="ui horizontal small divided link list">
<a class="item" href="fixed.php#">站点地图</a>
<a class="item" href="fixed.php#">关于这里</a>
<a class="item" href="fixed.php#">团队介绍</a>
<a class="item" href="fixed.php#">隐私政策</a>
</div>
<div style="margin-top:8px; color:#aaa; font-size:.82em;">
Powered by <strong>Jiscuss V2.0</strong>
</div>
</div>
</div>
<script>if(typeof NProgress !== 'undefined') NProgress.done();</script>
+113 -23
View File
@@ -1,42 +1,132 @@
<div class="ui main menu" id="menu">
<div class="ui container">
<div href="/" class="header borderless item">
<img class="logo" src="static/assets/images/logo.png">
<img class="logo" src="/static/assets/images/logo.png">
&nbsp;Jiscuss
</div>
<a class=" item borderless" href="/">首页</a>
<a class="active borderless item">论坛</a>
<div class="ui dropdown item borderless" tabindex="0">
更多
<i class="dropdown icon"></i>
<div class="menu transition hidden" tabindex="-1">
<a class=" item " href="/user">用户页</a>
<div class="divider"></div>
<div class="item">分割链接</div>
<div class="divider"></div>
<a class="item" href="/admin/home">后台管理</a>
</div>
</div>
<div class="right menu">
<div class="item">
<div class="ui transparent icon input">
<div class="ui transparent icon input" id="headerSearchBox">
<i class="search icon"></i>
<input type="text" placeholder="搜索">
<input type="text" id="headerSearchInput" placeholder="搜索" onkeydown="if(event.key==='Enter'&&this.value.trim()){window.location='/search?q='+encodeURIComponent(this.value.trim());}">
</div>
</div>
<div class="item" id="userlogin">
<#if username??>
<form class="ui large form" action="/logout" id="logoutForm" method="post" style="display: none;">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
</form>
${username} &nbsp;&nbsp;<a href="javascript:document.getElementById('logoutForm').submit();">注销</a>
<#else>
<a href="/login" id="loginmodel">登录&nbsp;&nbsp;&&nbsp;&nbsp;注册</a>
</#if>
<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>
<#if username??>
<div class="item">
<a href="/notifications" title="通知" style="position:relative; cursor:pointer; color:inherit;">
<i class="bell icon"></i>
<span id="headerNotifBadge" class="ui circular mini red label"
style="display:none; position:absolute; top:-4px; right:-8px; font-size:10px; padding:2px 4px;"></span>
</a>
</div>
<div class="item">
<a href="/messages" title="私信" style="position:relative; cursor:pointer; color:inherit;">
<i class="envelope icon"></i>
<span id="headerMsgBadge" class="ui circular mini red label"
style="display:none; position:absolute; top:-4px; right:-8px; font-size:10px; padding:2px 4px;"></span>
</a>
</div>
<div class="ui dropdown item borderless" tabindex="0">
<i class="user circle icon"></i>${username}
<i class="dropdown icon"></i>
<div class="menu transition hidden" tabindex="-1">
<a class="item" href="/user"><i class="id card outline icon"></i>&nbsp;个人主页</a>
<#if isAdmin?? && isAdmin>
<div class="divider"></div>
<a class="item" href="/admin/home"><i class="settings icon"></i>&nbsp;后台管理</a>
</#if>
<div class="divider"></div>
<a class="item" onclick="document.getElementById('logoutForm').submit();" style="cursor:pointer;">
<i class="sign out alternate icon"></i>&nbsp;注销
</a>
</div>
</div>
<form action="/logout" id="logoutForm" method="post" style="display:none;">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
</form>
<#else>
<div class="item" id="userlogin">
<a href="/login" id="loginmodel">登录&nbsp;&nbsp;&amp;&nbsp;&nbsp;注册</a>
</div>
</#if>
</div>
</div>
</div>
<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() {
// Initialize user dropdown
$('.ui.dropdown').dropdown();
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);
};
}
// Fetch unread counts and update header badges
function updateBadges() {
$.get('/msg_api/unread-counts', function(resp) {
if (!resp || resp.code !== 10000) return;
var d = resp.data || {};
var notifBadge = document.getElementById('headerNotifBadge');
var msgBadge = document.getElementById('headerMsgBadge');
if (notifBadge) {
if (d.notifications > 0) { notifBadge.textContent = d.notifications > 99 ? '99+' : d.notifications; notifBadge.style.display = ''; }
else { notifBadge.style.display = 'none'; }
}
if (msgBadge) {
if (d.messages > 0) { msgBadge.textContent = d.messages > 99 ? '99+' : d.messages; msgBadge.style.display = ''; }
else { msgBadge.style.display = 'none'; }
}
});
}
if (document.getElementById('headerNotifBadge')) {
updateBadges();
setInterval(updateBadges, 60000); // refresh every 60s
}
// NProgress — stop loading bar when page is ready
if (typeof NProgress !== 'undefined') NProgress.done();
// Back-to-top button
var $top = document.getElementById('backToTop');
if ($top) {
window.addEventListener('scroll', function() {
$top.style.display = window.scrollY > 300 ? 'flex' : 'none';
});
$top.addEventListener('click', function() {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
});
})();
</script>
<#-- 全局返回顶部按钮 -->
<button id="backToTop" title="返回顶部"><i class="angle up icon" style="margin:0;"></i></button>
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html>
<head>
<title>与 ${otherUsername} 的对话 - Jiscuss</title>
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
<#include "comm/commjs.ftl"/>
<style>
.msg-bubble { max-width: 65%; padding: 10px 14px; border-radius: 12px; margin: 6px 0; display: inline-block; word-break: break-word; }
.msg-mine { background: #2185d0; color: #fff; border-bottom-right-radius: 2px; }
.msg-other { background: #fff; color: #333; border: 1px solid #e0e0e0; border-bottom-left-radius: 2px; }
.msg-row { display: flex; margin: 4px 0; }
.msg-row.mine { justify-content: flex-end; }
.msg-row.other { justify-content: flex-start; }
.msg-time { font-size: 0.75em; color: #aaa; margin: 2px 8px; align-self: flex-end; }
#chatBox { height: 450px; overflow-y: auto; padding: 16px; background: #f7f8fa; border: 1px solid #e0e0e0; border-radius: 8px; }
#msgInput { resize: none; border-radius: 6px; }
</style>
</head>
<body style="background:#f7f8fa;">
<#include "comm/header.ftl"/>
<div class="ui container" style="margin-top:2em; margin-bottom:4em;">
<div class="ui breadcrumb" style="margin-bottom:1em;">
<a class="section" href="/messages"><i class="envelope icon"></i> 收件箱</a>
<div class="divider"> / </div>
<div class="active section">${otherUsername}</div>
</div>
<h3 class="ui header">${otherUsername}</h3>
<div id="chatBox">
<#if messages?has_content>
<#list messages as msg>
<div class="msg-row <#if msg.senderId == currentUserId>mine<#else>other</#if>">
<#if msg.senderId != currentUserId>
<div class="msg-bubble msg-other">${msg.content}</div>
<span class="msg-time">${msg.createTime?string("HH:mm")}</span>
<#else>
<span class="msg-time">${msg.createTime?string("HH:mm")}</span>
<div class="msg-bubble msg-mine">${msg.content}</div>
</#if>
</div>
</#list>
<#else>
<div style="text-align:center; color:#aaa; padding:60px 0;">暂无消息,发送第一条吧!</div>
</#if>
</div>
<div class="ui form" style="margin-top:1em;">
<div class="field">
<textarea id="msgInput" rows="3" placeholder="输入消息..." style="width:100%;"></textarea>
</div>
<button class="ui primary button" id="sendBtn">
<i class="send icon"></i> 发送
</button>
</div>
</div>
<#include "comm/footer.ftl"/>
<script>
var otherId = ${otherId};
var csrfToken = $('meta[name="_csrf"]').attr('content');
var csrfHeader = $('meta[name="_csrf_header"]').attr('content');
$('#sendBtn').on('click', function() {
var content = $('#msgInput').val().trim();
if (!content) return;
$(this).addClass('loading');
var headers = {};
headers[csrfHeader] = csrfToken;
$.ajax({
url: '/msg_api/messages/send',
type: 'POST',
headers: headers,
data: { receiverId: otherId, content: content },
success: function(resp) {
$('#sendBtn').removeClass('loading');
if (resp && resp.code === 10000) {
$('#msgInput').val('');
// Append new bubble
var now = new Date();
var timeStr = (now.getHours()<10?'0':'')+now.getHours()+':'+(now.getMinutes()<10?'0':'')+now.getMinutes();
var html = '<div class="msg-row mine">' +
'<span class="msg-time">'+timeStr+'</span>' +
'<div class="msg-bubble msg-mine">'+$('<div>').text(content).html()+'</div>' +
'</div>';
$('#chatBox').append(html);
$('#chatBox').scrollTop($('#chatBox')[0].scrollHeight);
} else {
alert(resp.message || '发送失败');
}
},
error: function() {
$('#sendBtn').removeClass('loading');
alert('发送失败,请重试');
}
});
});
// Allow Ctrl+Enter to send
$('#msgInput').on('keydown', function(e) {
if (e.ctrlKey && e.keyCode === 13) { $('#sendBtn').click(); }
});
// Scroll to bottom on load
$(document).ready(function() {
var box = document.getElementById('chatBox');
box.scrollTop = box.scrollHeight;
});
</script>
</body>
</html>
+222 -100
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<title>主题详情页</title>
<head>
<title>主题详情页</title>
<!-- <link rel="stylesheet" type="text/css" href="static/semanticui/semantic.css"> -->
<!-- <script src="static/jquery/jquery-3.4.1.min.js"></script> -->
<!-- <script src="static/semanticui/semantic.min.js"></script> -->
@@ -45,13 +45,25 @@
</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">
1,048
<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} 浏览
&nbsp;&nbsp;<i class="comment icon"></i> ${discussions.commentsCount!0} 回复
</span>
<div class="ui feed">
@@ -86,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=""/>
<!-- 定义遍历方法 -->
@@ -101,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">
@@ -123,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>
@@ -158,10 +187,10 @@
</div>
<div class="widescreen large screen computer tablet only four wide column">
<div class="ui fluid action input">
<input type="text" placeholder="搜索...">
<div class="ui button">搜索</div>
</div>
<form action="/search" method="get" class="ui fluid action input">
<input type="text" name="q" placeholder="搜索主题..." id="sidebarSearch">
<button type="submit" class="ui button">搜索</button>
</form>
<div class="ui section divider"></div>
@@ -170,117 +199,70 @@
<div class="header">作者</div>
</div>
<div class="content">
<h4 class="ui sub header">测试人员1</h4>
<div class="ui small feed">
<div class="event">
<div class="content">
<div class="summary">
<a>Jiscuss</a> 一直, <a>Jiscuss</a> 一感谢支持!
</div>
</div>
<h4 class="ui sub header">
<a class="user-card-trigger" data-user-id="${discussions.startUserId!''}">${discussions.realname!''}</a>
</h4>
<div class="ui small statistics" style="margin-top:8px;">
<div class="statistic">
<div class="value">${authorDiscCount!0}</div>
<div class="label">发帖</div>
</div>
<div class="event">
<div class="content">
<div class="summary">
<a>2019</a> 测试内容
</div>
</div>
<div class="statistic">
<div class="value">${authorReplyCount!0}</div>
<div class="label">回复</div>
</div>
</div>
</div>
<div class="extra content">
<button class="ui button">预留按钮</button>
</div>
</div>
<#if authorTopDiscs?? && authorTopDiscs?size gt 0>
<div class="ui card">
<div class="content">
<div class="header">作者热门主题</div>
<div class="header">作者其他主题</div>
</div>
<div class="content">
<div class="content" style="padding:0;">
<div class="ui middle aligned divided list">
<div class="item">
<img class="ui avatar image" src="/static/assets/images/logo.png">
<#list authorTopDiscs as d>
<#if d.id != discussions.id>
<div class="item" style="padding:8px 12px;">
<div class="content">
<a class="header">its here</a>
<a class="header" href="/getdiscussionsbyid?id=${d.id}" style="font-size:.9em;font-weight:normal;">
${(d.title?length > 30)?then(d.title?substring(0,30) + '…', d.title)}
</a>
<div class="description" style="font-size:.8em;color:#999;">
<i class="heart icon"></i>${d.likeCount!0}
&nbsp;<i class="comment icon"></i>${d.commentsCount!0}
</div>
</div>
</div>
<div class="item">
<img class="ui avatar image" src="/static/assets/images/logo.png">
<div class="content">
<a class="header">他的测试主题2</a>
</div>
</div>
<div class="item">
<img class="ui avatar image" src="/static/assets/images/logo.png">
<div class="content">
<a class="header">他的测试主题3</a>
</div>
</div>
</div>
</div>
<div class="extra content">
<p>预留信息</p>
</div>
</div>
<div class="ui card">
<div class="content">
<img class="right floated mini ui image"
src="static/assets/images/logo.png">
<div class="header">Jiscuss Demo</div>
<div class="meta">简单、易用、开源</div>
<div class="description">一款基于JAVA的开源论坛 框架采用:Spring Boot + H2
+ Semantic UI
</div>
</div>
<div class="extra content">
<div class="ui two buttons">
<div class="ui basic red button">
<div class="ui mini statistic">
<div class="value">1</div>
<div class="label">用户数</div>
</div>
</div>
<div class="ui basic green button">
<div class="ui mini statistic">
<div class="value">10</div>
<div class="label">发帖数</div>
</div>
</div>
</#if>
</#list>
</div>
</div>
</div>
</#if>
</div>
<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} 浏览 &nbsp;
<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>
@@ -299,10 +281,150 @@
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;"></div>').appendTo('body');
function showCard(u, x, y) {
var lvlNames = ['新手','学徒','熟手','达人','专家','大神'];
var lvlColors = ['#95a5a6','#27ae60','#2980b9','#8e44ad','#e67e22','#e74c3c'];
var lvl = Math.min(5, Math.max(0, u.level || 0));
var badge = '<span style="display:inline-block;padding:1px 8px;border-radius:10px;background:' + lvlColors[lvl] + ';color:#fff;font-size:.78em;font-weight:bold;">Lv.' + lvl + ' ' + lvlNames[lvl] + '</span>';
$card.html(
'<div style="font-weight:700;font-size:1em;margin-bottom:4px;">' + (u.username || '') + ' ' + badge + '</div>' +
'<div style="color:#888;font-size:.88em;">发帖: ' + (u.discussionsCount || 0) +
'&nbsp;&nbsp;回复: ' + (u.commentsCount || 0) +
'&nbsp;&nbsp;积分: ' + (u.score || 0) + '</div>' +
'<div style="margin-top:8px;display:flex;gap:12px;">' +
'<a href="/profile?uid=' + u.id + '" style="font-size:.85em;color:#2185d0;"><i class="user icon"></i>主页</a>' +
'<a class="open-msg-modal" data-uid="' + u.id + '" data-uname="' + (u.username||'') + '" style="font-size:.85em;color:#2185d0;cursor:pointer;"><i class="envelope icon"></i>发私信</a>' +
'</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('/msg_api/user-card/' + 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() {
// Delay hide so user can click links inside card
setTimeout(function() {
if (!$card.is(':hover')) $card.hide();
}, 300);
});
$card.on('mouseleave', 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>
<#-- 私信弹窗 Modal -->
<div class="ui small modal" id="sendMsgModal">
<div class="header"><i class="envelope icon"></i> 发送私信给 <span id="msgModalUsername"></span></div>
<div class="content">
<div class="ui form">
<div class="field">
<textarea id="msgModalContent" rows="4" placeholder="输入消息内容..." style="resize:none;"></textarea>
</div>
<div id="msgModalError" class="ui red message" style="display:none;"></div>
</div>
</div>
<div class="actions">
<div class="ui cancel button">取消</div>
<div class="ui primary button" id="msgModalSendBtn">
<i class="send icon"></i>发送
</div>
</div>
</div>
<script>
var _msgReceiverId = null;
var _msgCsrfToken = $('meta[name="_csrf"]').attr('content');
var _msgCsrfHeader = $('meta[name="_csrf_header"]').attr('content');
// Open modal when user clicks 发私信 in user card
$(document).on('click', '.open-msg-modal', function() {
_msgReceiverId = $(this).data('uid');
var uname = $(this).data('uname');
$('#msgModalUsername').text(uname);
$('#msgModalContent').val('');
$('#msgModalError').hide();
$('#jUserCard').hide();
$('#sendMsgModal').modal('show');
});
$('#msgModalSendBtn').on('click', function() {
var content = $('#msgModalContent').val().trim();
if (!content) { $('#msgModalError').text('消息内容不能为空').show(); return; }
var $btn = $(this).addClass('loading disabled');
var headers = {};
headers[_msgCsrfHeader] = _msgCsrfToken;
$.ajax({
url: '/msg_api/messages/send',
type: 'POST',
headers: headers,
data: { receiverId: _msgReceiverId, content: content },
success: function(resp) {
$btn.removeClass('loading disabled');
if (resp && resp.code === 10000) {
$('#sendMsgModal').modal('hide');
massage('私信发送成功!', 'success', '');
} else {
$('#msgModalError').text((resp && resp.msg) || '发送失败,请重试').show();
}
},
error: function() {
$btn.removeClass('loading disabled');
$('#msgModalError').text('请求失败,请确认已登录').show();
}
});
});
</script>
</body>
</html>
+171 -83
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<title>Jiscuss Demo</title>
<head>
<title>Jiscuss Demo</title>
<!-- default header name is X-CSRF-TOKEN -->
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
@@ -226,33 +226,27 @@
</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="content">
<div class="summary">
<a>Jiscuss</a> 会持续更新, <a>Jiscuss</a> 将一直更新完善,感谢支持!
<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">
<a class="header">${u.username}</a>
<div class="description">${u.commentsCount!0} 回复</div>
</div>
</div>
</div>
<div class="event">
<div class="content">
<div class="summary">
<a>2019</a> 测试内容
</div>
</div>
</div>
</#list>
<#else>
<div class="item" style="color:#aaa;">暂无数据</div>
</#if>
</div>
</div>
<div class="extra content">
<button class="ui button">预留按钮</button>
</div>
</div>
@@ -339,18 +333,20 @@
</div>
<div class="meta">
<div class=" description "><i class="edit icon"></i>
<a data-tooltip="${discussions.username}"
data-position="top center"><b>${discussions.username}</b></a>&nbsp;&nbsp;•&nbsp;&nbsp;
<a class="user-card-trigger" data-user-id="${discussions.startUserId!''}"><b>${discussions.username}</b></a>&nbsp;&nbsp;•&nbsp;&nbsp;
<i class="reply icon"></i>最后由 <a><b>${discussions.usernameLast}</b></a>
回复于${discussions.lastTime}.
</div>
</div>
<div class="nullright meta">
<a class="like ">
<i class="like icon"></i> ${discussions.likeCount} 喜欢
<i class="like icon"></i> ${discussions.likeCount!0} 喜欢
</a>
<a class="comment ">
<i class="comment icon"></i> ${discussions.commentsCount} 回复
<i class="comment icon"></i> ${discussions.commentsCount!0} 回复
</a>
<a class="comment ">
<i class="eye icon"></i> ${discussions.viewCount!0} 浏览
</a>
</div>
</div>
@@ -390,17 +386,20 @@
</div>
<div class="meta">
<div class=" description "><i class="edit icon"></i>
<a><b>${discussions.username}</b></a>&nbsp;&nbsp;•&nbsp;&nbsp;
<a class="user-card-trigger" data-user-id="${discussions.startUserId!''}"><b>${discussions.username}</b></a>&nbsp;&nbsp;•&nbsp;&nbsp;
<i class="reply icon"></i>最后由 <a><b>${discussions.usernameLast}</b></a>
回复于${discussions.lastTime}.
</div>
</div>
<div class="nullright meta">
<a class="like ">
<i class="like icon"></i> ${discussions.likeCount} 喜欢
<i class="like icon"></i> ${discussions.likeCount!0} 喜欢
</a>
<a class="comment ">
<i class="comment icon"></i> ${discussions.commentsCount} 回复
<i class="comment icon"></i> ${discussions.commentsCount!0} 回复
</a>
<a class="comment ">
<i class="eye icon"></i> ${discussions.viewCount!0} 浏览
</a>
</div>
</div>
@@ -439,17 +438,20 @@
</div>
<div class="meta">
<div class=" description "><i class="edit icon"></i>
<a><b>${discussions.username}</b></a>&nbsp;&nbsp;•&nbsp;&nbsp;
<a class="user-card-trigger" data-user-id="${discussions.startUserId!''}"><b>${discussions.username}</b></a>&nbsp;&nbsp;•&nbsp;&nbsp;
<i class="reply icon"></i>最后由 <a><b>${discussions.usernameLast}</b></a>
回复于${discussions.lastTime}.
</div>
</div>
<div class="nullright meta">
<a class="like ">
<i class="like icon"></i> ${discussions.likeCount} 喜欢
<i class="like icon"></i> ${discussions.likeCount!0} 喜欢
</a>
<a class="comment ">
<i class="comment icon"></i> ${discussions.commentsCount} 回复
<i class="comment icon"></i> ${discussions.commentsCount!0} 回复
</a>
<a class="comment ">
<i class="eye icon"></i> ${discussions.viewCount!0} 浏览
</a>
</div>
</div>
@@ -461,68 +463,68 @@
</div>
<#-- Pagination: preserves tag and type query parameters -->
<div class="ui borderless menu">
<a class="icon item" id="upPage">
<i class="left chevron icon"></i>
</a>
<#assign baseUrl = "/?tag=" + tag + "&type=" + type>
<#if pageNum gt 1>
<a class="icon item" href="${baseUrl}&pageNum=${pageNum - 1}">
<i class="left chevron icon"></i>
</a>
<#else>
<div class="disabled icon item"><i class="left chevron icon"></i></div>
</#if>
<#list pageDiscussions as page>
<#if page == pageNum && !(username)??>
<a class="item" style=" background-color: #7d827d;" href="/?pageNum=${page}">
${page}
</a>
</#if>
<#if page != pageNum && !(username)??>
<a class="item" href="/?pageNum=${page}">
${page}
</a>
</#if>
<#if page == pageNum && username??>
<a class="item" style=" background-color: #7d827d;" href="/main?pageNum=${page}">
${page}
</a>
</#if>
<#if page != pageNum && username??>
<a class="item" href="/main?pageNum=${page}">
${page}
</a>
<#assign pageInt = page?number>
<#if pageInt == pageNum>
<a class="active item" href="${baseUrl}&pageNum=${page}">${page}</a>
<#else>
<a class="item" href="${baseUrl}&pageNum=${page}">${page}</a>
</#if>
</#list>
<a class="icon item" id="nextPage"> <i class="right chevron icon"></i>
</a>
<#if pageNum lt pageTotalPages>
<a class="icon item" href="${baseUrl}&pageNum=${pageNum + 1}">
<i class="right chevron icon"></i>
</a>
<#else>
<div class="disabled icon item"><i class="right chevron icon"></i></div>
</#if>
</div>
<!--pager-->
<#--<div class="ui borderless menu">
<ul class="pagination">
<#import "./comm/page.ftl" as page />
<@page.fpage page=pageNum pagesize=pageSize totalpages=pageTotalPages totalrecords=pageTotal url="/" />
</ul>
</div>-->
</div>
<div class="mobile only sixteen wide column">
<div class="ui segment">Jiscuss手机可见,内容正在编写中,pc端可正常访问</div>
<div class="ui large feed">
<#list allDiscussions as discussions>
<div class="event">
<div class="content">
<div class="summary">
<a href="/getdiscussionsbyid?id=${discussions.id}">
${discussions.title}
</a>
<div class="date">${discussions.startTime}</div>
</div>
<div class="meta">
<a class="user-card-trigger" data-user-id="${discussions.startUserId!''}"><b>${discussions.username}</b></a>&nbsp;•&nbsp;
<i class="comment icon"></i>${discussions.commentsCount!0} 回复&nbsp;
<i class="eye icon"></i>${discussions.viewCount!0} 浏览
</div>
</div>
</div>
<div class="ui fitted divider"></div>
</#list>
</div>
<div class="ui borderless 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>
<a class="item">
10
</a>
<a class="item">
11
</a>
<a class="icon item"> <i class="right chevron icon"></i>
</a>
<#if pageNum gt 1>
<a class="icon item" href="${baseUrl}&pageNum=${pageNum - 1}"><i class="left chevron icon"></i></a>
<#else>
<div class="disabled icon item"><i class="left chevron icon"></i></div>
</#if>
<span class="item">${pageNum} / ${pageTotalPages}</span>
<#if pageNum lt pageTotalPages>
<a class="icon item" href="${baseUrl}&pageNum=${pageNum + 1}"><i class="right chevron icon"></i></a>
<#else>
<div class="disabled icon item"><i class="right chevron icon"></i></div>
</#if>
</div>
</div>
@@ -553,6 +555,92 @@
</script>
<script type="text/javascript" charset="UTF-8" src="/static/js/user/system.js"></script>
<#-- 用户悬浮卡片 -->
<script>
(function() {
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;"></div>').appendTo('body');
function showCard(u, x, y) {
var lvlNames = ['新手','学徒','熟手','达人','专家','大神'];
var lvlColors = ['#95a5a6','#27ae60','#2980b9','#8e44ad','#e67e22','#e74c3c'];
var lvl = Math.min(5, Math.max(0, u.level || 0));
var badge = '<span style="display:inline-block;padding:1px 8px;border-radius:10px;background:' + lvlColors[lvl] + ';color:#fff;font-size:.78em;font-weight:bold;">Lv.' + lvl + ' ' + lvlNames[lvl] + '</span>';
$card.html(
'<div style="font-weight:700;font-size:1em;margin-bottom:4px;">' + (u.username || '') + ' ' + badge + '</div>' +
'<div style="color:#888;font-size:.88em;">发帖: ' + (u.discussionsCount || 0) +
'&nbsp;&nbsp;回复: ' + (u.commentsCount || 0) +
'&nbsp;&nbsp;积分: ' + (u.score || 0) + '</div>' +
'<div style="margin-top:8px;display:flex;gap:12px;">' +
'<a href="/profile?uid=' + u.id + '" style="font-size:.85em;color:#2185d0;"><i class="user icon"></i>主页</a>' +
'<a class="open-msg-modal" data-uid="' + u.id + '" data-uname="' + (u.username||'') + '" style="font-size:.85em;color:#2185d0;cursor:pointer;"><i class="envelope icon"></i>发私信</a>' +
'</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('/msg_api/user-card/' + 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() {
setTimeout(function() { if (!$card.is(':hover')) $card.hide(); }, 300);
});
$card.on('mouseleave', function() { $card.hide(); });
})();
</script>
<#-- 私信弹窗 Modal -->
<div class="ui small modal" id="sendMsgModal">
<div class="header"><i class="envelope icon"></i> 发送私信给 <span id="msgModalUsername"></span></div>
<div class="content">
<div class="ui form">
<div class="field">
<textarea id="msgModalContent" rows="4" placeholder="输入消息内容..." style="resize:none;"></textarea>
</div>
<div id="msgModalError" class="ui red message" style="display:none;"></div>
</div>
</div>
<div class="actions">
<div class="ui cancel button">取消</div>
<div class="ui primary button" id="msgModalSendBtn"><i class="send icon"></i>发送</div>
</div>
</div>
<script>
var _msgReceiverId = null;
var _msgCsrfToken = $('meta[name="_csrf"]').attr('content');
var _msgCsrfHeader = $('meta[name="_csrf_header"]').attr('content');
$(document).on('click', '.open-msg-modal', function() {
_msgReceiverId = $(this).data('uid');
$('#msgModalUsername').text($(this).data('uname'));
$('#msgModalContent').val('');
$('#msgModalError').hide();
$('#jUserCard').hide();
$('#sendMsgModal').modal('show');
});
$('#msgModalSendBtn').on('click', function() {
var content = $('#msgModalContent').val().trim();
if (!content) { $('#msgModalError').text('消息内容不能为空').show(); return; }
var $btn = $(this).addClass('loading disabled');
var headers = {}; headers[_msgCsrfHeader] = _msgCsrfToken;
$.ajax({
url: '/msg_api/messages/send', type: 'POST', headers: headers,
data: { receiverId: _msgReceiverId, content: content },
success: function(resp) {
$btn.removeClass('loading disabled');
if (resp && resp.code === 10000) { $('#sendMsgModal').modal('hide'); }
else { $('#msgModalError').text((resp && resp.msg) || '发送失败').show(); }
},
error: function() { $btn.removeClass('loading disabled'); $('#msgModalError').text('请先登录').show(); }
});
});
</script>
</body>
</html>
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<title>Jiscuss Demo</title>
<head>
<title>Jiscuss Demo</title>
<#include "comm/commjs.ftl"/>
<style type="text/css">
body {
+59
View File
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html>
<head>
<title>私信收件箱 - Jiscuss</title>
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
<#include "comm/commjs.ftl"/>
</head>
<body style="background:#f7f8fa;">
<#include "comm/header.ftl"/>
<div class="ui container" style="margin-top:2em; margin-bottom:4em;">
<h2 class="ui header">
<i class="envelope icon"></i>
<div class="content">私信收件箱
<#if unreadMsgCount?? && (unreadMsgCount > 0)>
<span class="ui mini red label">${unreadMsgCount} 未读</span>
</#if>
</div>
</h2>
<div class="ui divider"></div>
<#if inboxItems?has_content>
<div class="ui relaxed divided list">
<#list inboxItems as item>
<a class="item" href="/messages/${item.otherId}"
style="padding:1em 0; display:flex; align-items:center; gap:1em; color:inherit; text-decoration:none; cursor:pointer;">
<div class="ui circular label"
style="min-width:40px;height:40px;line-height:40px;text-align:center;font-size:1.1em;background:#2185d0;color:#fff;">
${(item.otherUsername?has_content)?then(item.otherUsername?substring(0,1)?upper_case, "?")}
</div>
<div style="flex:1; overflow:hidden;">
<div style="font-weight:bold;">${item.otherUsername}</div>
<div style="color:#888; font-size:0.9em; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:450px;">
${item.lastContent}
</div>
</div>
<div style="color:#aaa; font-size:0.85em; white-space:nowrap;">
<#if item.lastTime??>${item.lastTime?string("MM-dd HH:mm")}</#if>
</div>
<#if item.hasUnread>
<span class="ui mini red label">未读</span>
</#if>
</a>
</#list>
</div>
<#else>
<div class="ui placeholder segment">
<div class="ui icon header">
<i class="envelope open outline icon"></i>
暂无私信
</div>
</div>
</#if>
</div>
<#include "comm/footer.ftl"/>
</body>
</html>
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<title>新建主题页</title>
<head>
<title>新建主题页</title>
<!-- <link rel="stylesheet" type="text/css" href="static/semanticui/semantic.css"> -->
<!-- <script src="static/jquery/jquery-3.4.1.min.js"></script> -->
<!-- <script src="static/semanticui/semantic.min.js"></script> -->
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<title>新建标签页</title>
<head>
<title>新建标签页</title>
<!-- <link rel="stylesheet" type="text/css" href="static/semanticui/semantic.css"> -->
<!-- <script src="static/jquery/jquery-3.4.1.min.js"></script> -->
<!-- <script src="static/semanticui/semantic.min.js"></script> -->
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html>
<head>
<title>通知 - Jiscuss</title>
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
<#include "comm/commjs.ftl"/>
</head>
<body style="background:#f7f8fa;">
<#include "comm/header.ftl"/>
<div class="ui container" style="margin-top:2em; margin-bottom:4em;">
<h2 class="ui header">
<i class="bell icon"></i>
<div class="content">通知</div>
</h2>
<div class="ui divider"></div>
<#if notifications?has_content>
<div class="ui feed" style="background:#fff; border-radius:8px; padding:1em;">
<#list notifications as notif>
<div class="event" style="padding:1em 0; border-bottom:1px solid #f0f0f0;">
<div class="label" style="float:left; margin-right:1em;">
<#if notif.type == "reply">
<i class="reply blue icon" style="font-size:1.5em;"></i>
<#elseif notif.type == "like">
<i class="heart red icon" style="font-size:1.5em;"></i>
<#else>
<i class="info circle grey icon" style="font-size:1.5em;"></i>
</#if>
</div>
<div class="content" style="margin-left:3em;">
<div class="summary">
<#if fromUsernames?has_content && notif?index < fromUsernames?size>
<strong>${fromUsernames[notif?index]}</strong>
</#if>
${notif.title!"通知"}
<#if notif.relatedId?? && notif.relatedType?? && notif.relatedType == "discussion">
&nbsp;<a href="/getdiscussionsbyid?id=${notif.relatedId}">查看帖子</a>
<#elseif notif.relatedId?? && notif.relatedType?? && notif.relatedType == "post">
&nbsp;<a href="#">查看回复</a>
</#if>
</div>
<#if notif.content??>
<div class="extra text" style="color:#666; margin-top:4px;">${notif.content}</div>
</#if>
<div class="date" style="color:#aaa; font-size:0.85em; margin-top:4px;">
<#if notif.createTime??>${notif.createTime?string("yyyy-MM-dd HH:mm")}</#if>
</div>
</div>
</div>
</#list>
</div>
<#if (totalPages > 1)>
<div class="ui center aligned basic segment">
<div class="ui pagination menu">
<#list 0..(totalPages-1) as p>
<a class="item <#if p == currentPage>active</#if>" href="/notifications?page=${p}">${p+1}</a>
</#list>
</div>
</div>
</#if>
<#else>
<div class="ui placeholder segment">
<div class="ui icon header">
<i class="bell slash outline icon"></i>
暂无通知
</div>
</div>
</#if>
</div>
<#include "comm/footer.ftl"/>
</body>
</html>
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<title>Jiscuss Demo</title>
<head>
<title>Jiscuss Demo</title>
<#include "comm/commjs.ftl"/>
<style type="text/css">
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<title>搜索 - Jiscuss</title>
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
<#include "comm/commjs.ftl"/>
</head>
<body style="background: #f7f8fa;">
<#include "comm/header.ftl"/>
<div class="ui container" id="container" style="margin-top: 20px;">
<div class="ui segment">
<form action="/search" method="get" class="ui form">
<div class="ui large fluid action input">
<input type="text" name="q" value="${q!''}" placeholder="搜索主题..." autofocus>
<button type="submit" class="ui primary button"><i class="search icon"></i>搜索</button>
</div>
</form>
</div>
<#if q?? && q != "">
<div class="ui secondary segment" style="margin-top:0; border-top:none;">
<#if totalElements?? && totalElements gt 0>
<span style="color:#555;">找到 <strong>${totalElements}</strong> 个关于 "<strong>${q}</strong>" 的结果</span>
<#else>
<span style="color:#888;">没有找到关于 "<strong>${q}</strong>" 的相关结果</span>
</#if>
</div>
</#if>
<#if results?? && results?size gt 0>
<div class="ui relaxed divided items" style="margin-top:10px;">
<#list results as disc>
<div class="item" style="padding: 14px 0; border-bottom: 1px solid #eee;">
<div class="content">
<a class="header" href="/getdiscussionsbyid?id=${disc.id}" style="font-size:1.1em; color:#2185d0;">
${disc.title}
</a>
<div class="description" style="margin-top:6px; color:#555; font-size:.9em; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; max-width:700px;">
${disc.content!''}
</div>
<div class="meta" style="margin-top:6px; color:#999; font-size:.85em;">
<i class="user icon"></i> <b>${disc.username!''}</b>
&nbsp;&nbsp;•&nbsp;&nbsp;
<i class="clock outline icon"></i> ${disc.startTime!''}
&nbsp;&nbsp;
<i class="like icon"></i> ${disc.likeCount!0}
&nbsp;
<i class="comment icon"></i> ${disc.commentsCount!0} 回复
&nbsp;
<i class="eye icon"></i> ${disc.viewCount!0} 浏览
<#if disc.tagList?? && disc.tagList?size gt 0>
&nbsp;&nbsp;
<#list disc.tagList as tag>
<a class="ui mini ${tag.color!''} label">${tag.name}</a>
</#list>
</#if>
</div>
</div>
</div>
</#list>
</div>
<#if totalPages?? && totalPages gt 1>
<div class="ui pagination menu" style="margin-top:20px;">
<#if pageNum gt 1>
<a class="item" href="/search?q=${q?url}&pageNum=${pageNum - 1}"><i class="left chevron icon"></i>上一页</a>
</#if>
<#list 1..totalPages as p>
<a class="item <#if p == pageNum>active</#if>" href="/search?q=${q?url}&pageNum=${p}">${p}</a>
</#list>
<#if pageNum lt totalPages>
<a class="item" href="/search?q=${q?url}&pageNum=${pageNum + 1}">下一页<i class="right chevron icon"></i></a>
</#if>
</div>
</#if>
<#elseif q?? && q != "">
<div class="ui placeholder segment" style="margin-top:10px;">
<div class="ui icon header">
<i class="search icon"></i>
暂无搜索结果
</div>
<a class="ui primary button" href="/">返回首页</a>
</div>
</#if>
</div>
<#include "comm/footer.ftl"/>
</body>
</html>
+214 -89
View File
@@ -1,78 +1,209 @@
<!DOCTYPE html>
<html>
<title>用户详情页</title>
<head>
<!-- <link rel="stylesheet" type="text/css" href="static/semanticui/semantic.css"> -->
<!-- <script src="static/jquery/jquery-3.4.1.min.js"></script> -->
<!-- <script src="static/semanticui/semantic.min.js"></script> -->
<title>${username!}的个人主页 - Jiscuss</title>
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
<#include "comm/commjs.ftl"/>
<style>
.profile-card { background:#fff; border-radius:10px; padding:24px; box-shadow:0 1px 4px rgba(0,0,0,.08); margin-bottom:1.5em; }
.profile-stat { text-align:center; }
.profile-stat .number { font-size:1.6em; font-weight:bold; color:var(--color-primary,#2185d0); }
.profile-stat .label2 { color:#888; font-size:0.85em; }
.tab-content { background:#fff; border-radius:8px; padding:1.2em; min-height:200px; }
/* 彩蛋按钮特效 */
@keyframes rainbowBg {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes btnGlow {
0%, 100% { box-shadow: 0 0 8px 2px rgba(41,128,185,.6); }
33% { box-shadow: 0 0 10px 3px rgba(192,57,43,.6); }
66% { box-shadow: 0 0 10px 3px rgba(39,174,96,.6); }
}
@keyframes btnWiggle {
0%,100% { transform: rotate(0deg) scale(1); }
20% { transform: rotate(-4deg) scale(1.08); }
40% { transform: rotate(4deg) scale(1.08); }
60% { transform: rotate(-3deg) scale(1.05); }
80% { transform: rotate(3deg) scale(1.05); }
}
@keyframes ripple {
to { transform: scale(3); opacity: 0; }
}
#userButton {
position: relative; overflow: hidden;
border: 2px solid transparent !important;
background: linear-gradient(#fff,#fff) padding-box,
linear-gradient(270deg,#e74c3c,#f39c12,#27ae60,#2980b9,#8e44ad,#e74c3c) border-box;
background-size: auto, 400% 400%;
animation: rainbowBg 4s ease infinite, btnGlow 3s ease-in-out infinite;
color: #333 !important;
font-weight: bold;
letter-spacing: .5px;
transition: transform .15s;
}
#userButton:hover {
animation: btnWiggle .5s ease, rainbowBg 4s ease infinite, btnGlow 3s ease-in-out infinite;
cursor: pointer;
}
#userButton .ripple-circle {
position: absolute; border-radius: 50%;
background: rgba(100,100,255,.25);
width: 60px; height: 60px;
margin-top: -30px; margin-left: -30px;
animation: ripple .6s linear;
pointer-events: none;
}
</style>
</head>
<body style=" background: #f7f8fa;">
<body style="background:#f7f8fa;">
<#include "comm/header.ftl"/>
<div class="ui container" id="container">
<h1>${username}的个人主页</h1>
<h2 class="ui dividing header">详情</h2>
<div class="ui vertical stripe segment">
<#-- <div class="ui middle aligned stackable grid container">-->
<div class="ui internally celled grid">
<div class="row">
<div class="eight wide column">
<div class="ui secondary menu">
<a class="item ${discussion}" href="/user?type=discussion">主题</a>
<a class="item ${change}" href="/user?type=change">动态</a>
<a class="item ${like}" href="/user?type=like">喜欢收藏</a>
</div>
<div class="ui bottom attached tab segment ${discussion}">
主题...
</div>
<div class="ui bottom attached tab segment ${change}">
动态...
</div>
<div class="ui bottom attached tab segment ${like}">
喜欢收藏...
</div>
</div>
<div class="six wide right floated column">
<div class="ui link cards">
<div class="card">
<div class="image">
<img src="static/assets/images/logo.png">
</div>
<div class="content">
<div class="header">Matt Giampietro</div>
<div class="meta">
<a>Friends</a>
</div>
<div class="description">
Matthew is an interior designer living in New York.
</div>
</div>
<div class="extra content">
<span class="right floated">
Joined in 2013
</span>
<span>
<i class="user icon"></i>
75 Friends
</span>
</div>
<div class="ui container" style="margin-top:2em; margin-bottom:4em;">
<div class="ui grid stackable">
<#-- Left: profile info -->
<div class="four wide column">
<div class="profile-card" style="text-align:center;">
<div class="ui circular image" style="width:80px;height:80px;margin:0 auto 1em;">
<#if userEntity?? && userEntity.avatar??>
<img src="${userEntity.avatar}" style="width:80px;height:80px;object-fit:cover;border-radius:50%;">
<#else>
<div style="width:80px;height:80px;border-radius:50%;background:#2185d0;color:#fff;font-size:2em;line-height:80px;text-align:center;">
${(username!"")?substring(0,1)?upper_case}
</div>
</#if>
</div>
<div style="font-size:1.2em; font-weight:bold;">${username!}</div>
<#if userEntity?? && userEntity.realname??>
<div style="color:#888; font-size:0.9em;">${userEntity.realname}</div>
</#if>
<#if userEntity?? && userEntity.joinTime??>
<div style="color:#aaa; font-size:0.8em; margin-top:4px;">
加入于 ${userEntity.joinTime?string("yyyy-MM-dd")}
</div>
</#if>
<div style="margin-top:14px;">
<#if isOwn!true>
<button class="ui basic button" id="userButton">
<i class="hand peace outline icon"></i>小按钮·点下试试
</button>
</#if>
</div>
</div>
<div class="row">
<div class="center aligned column">
<button class="ui basic button" id="userButton">
<i class="icon hand peace outline"></i>
小按钮·点下试试
</button>
<div class="profile-card">
<div class="ui three column grid" style="text-align:center;">
<div class="column profile-stat">
<div class="number">${liveDiscCount!0}</div>
<div class="label2">发帖</div>
</div>
<div class="column profile-stat">
<div class="number">${liveReplyCount!0}</div>
<div class="label2">回复</div>
</div>
<div class="column profile-stat">
<div class="number">${(userEntity.score)!0}</div>
<div class="label2">积分</div>
</div>
</div>
<div style="text-align:center; margin-top:10px;">
<#assign lvl = (userEntity.level)!0>
<#assign lvlName = ["新手","学徒","熟手","达人","专家","大神"][lvl?int]>
<#assign lvlColors = ["#95a5a6","#27ae60","#2980b9","#8e44ad","#e67e22","#e74c3c"]>
<span style="display:inline-block; padding:3px 12px; border-radius:20px;
background:${lvlColors[lvl?int]}; color:#fff; font-size:.85em; font-weight:bold;">
Lv.${lvl} ${lvlName}
</span>
</div>
</div>
</div>
<#-- Right: tabs -->
<div class="twelve wide column">
<div class="ui pointing secondary menu">
<a class="item <#if type == 'discussion'>active</#if>" href="/user?type=discussion">
<i class="list icon"></i>主题
</a>
<a class="item <#if type == 'reply'>active</#if>" href="/user?type=reply">
<i class="comment icon"></i>回复
</a>
<a class="item <#if type == 'like'>active</#if>" href="/user?type=like">
<i class="heart icon"></i>点赞
</a>
</div>
<div class="tab-content">
<#if type == 'discussion'>
<#if discussions?has_content>
<div class="ui divided list">
<#list discussions as d>
<div class="item" style="padding:10px 0; display:flex; justify-content:space-between; align-items:center;">
<div>
<a href="/getdiscussionsbyid?id=${d.id}" style="font-weight:500;">${d.title}</a>
<div style="color:#aaa; font-size:0.8em; margin-top:2px;">
<#if d.startTime??>${d.startTime?string("yyyy-MM-dd")}</#if>
</div>
</div>
<div style="color:#aaa; font-size:0.85em; white-space:nowrap; margin-left:1em;">
<i class="comment outline icon"></i>${(d.commentsCount)!0}
&nbsp;<i class="eye icon"></i>${(d.viewCount)!0}
</div>
</div>
</#list>
</div>
<#else>
<div class="ui placeholder segment" style="border:none; box-shadow:none;">
<div class="ui icon header"><i class="file outline icon"></i>暂无发帖记录</div>
</div>
</#if>
<#elseif type == 'reply'>
<#if posts?has_content>
<div class="ui divided list">
<#list posts as p>
<div class="item" style="padding:10px 0;">
<div style="color:#888; font-size:0.85em; margin-bottom:4px;">
<#if discTitles?has_content && p?index < discTitles?size>
回复了 <a href="/getdiscussionsbyid?id=${p.discussionId!0}">${discTitles[p?index]}</a>
</#if>
&nbsp;·&nbsp;
<#if p.createTime??>${p.createTime?string("MM-dd HH:mm")}</#if>
</div>
<div style="color:#333;"><#if (p.content?length > 150)>${p.content?html?substring(0, 150)}…<#else>${p.content?html}</#if></div>
</div>
</#list>
</div>
<#else>
<div class="ui placeholder segment" style="border:none; box-shadow:none;">
<div class="ui icon header"><i class="comment outline icon"></i>暂无回复记录</div>
</div>
</#if>
<#elseif type == 'like'>
<#if likedDiscussions?has_content>
<div class="ui divided list">
<#list likedDiscussions as d>
<div class="item" style="padding:10px 0; display:flex; justify-content:space-between; align-items:center;">
<div>
<a href="/getdiscussionsbyid?id=${d.id}" style="font-weight:500;">${d.title}</a>
<div style="color:#aaa; font-size:0.8em;">
<#if d.startTime??>${d.startTime?string("yyyy-MM-dd")}</#if>
</div>
</div>
<div style="color:#aaa; font-size:0.85em; white-space:nowrap; margin-left:1em;">
<i class="heart red icon"></i>${(d.likeCount)!0}
</div>
</div>
</#list>
</div>
<#else>
<div class="ui placeholder segment" style="border:none; box-shadow:none;">
<div class="ui icon header"><i class="heart outline icon"></i>暂无点赞记录</div>
</div>
</#if>
</#if>
</div>
</div>
</div>
@@ -80,39 +211,33 @@
<#include "comm/footer.ftl"/>
<#-- <div class="ui bottom vertical inverted sidebar labeled icon menu" id="userButtonsidebar">-->
<div class="ui bottom item three labeled icon sidebar menu scale down" id="userButtonsidebar">
<#-- <div class="ui bottom demo inverted nine item labeled icon sidebar menu scale down visible" style="">-->
<a class="item">
<i class="home icon"></i>
主题
<#if isOwn!true>
<div class="ui bottom three item labeled icon sidebar menu scale down" id="userButtonsidebar">
<a class="item" href="/user?type=discussion">
<i class="list icon"></i>主题
</a>
<a class="item">
<i class="block layout icon"></i>
动态
<a class="item" href="/user?type=reply">
<i class="comment icon"></i>回复
</a>
<a class="item">
<i class="smile icon"></i>
喜欢收藏
<a class="item" href="/user?type=like">
<i class="heart icon"></i>点赞
</a>
</div>
<script type="text/javascript">
$(document).ready(function () {
<#if username??>
setusername('${username}');
console.log('已经登陆:' + username);
</#if>
<#--setDiscussionsId('${discussions.id}');-->
$('#userTab')
.tab()
;
});
</script>
<script type="text/javascript" charset="UTF-8" src="/static/js/user/user.js"></script>
<script>
// Ripple effect on button click
document.getElementById('userButton') && document.getElementById('userButton').addEventListener('click', function(e) {
var btn = this;
var circle = document.createElement('span');
circle.classList.add('ripple-circle');
var rect = btn.getBoundingClientRect();
circle.style.left = (e.clientX - rect.left) + 'px';
circle.style.top = (e.clientY - rect.top) + 'px';
btn.appendChild(circle);
setTimeout(function(){ circle.remove(); }, 650);
});
</script>
</#if>
</body>
</html>