4 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
25 changed files with 968 additions and 176 deletions
@@ -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"));
}
}
@@ -18,6 +18,7 @@ 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;
@@ -100,6 +101,54 @@ public class UserPageController extends BaseController {
map.put("likedDiscussions", liked);
}
map.put("isOwn", true);
return "user";
}
/**
* 公开用户主页 /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:/";
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,6 +7,8 @@ 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;
@@ -19,6 +21,7 @@ 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;
@@ -59,12 +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;
/**
* 首页查看主题列表(各种条件筛选,最热最新标签等)
@@ -112,6 +124,19 @@ public class UserPostController extends BaseController {
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());
@@ -161,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;
}
@@ -220,6 +249,10 @@ public class UserPostController extends BaseController {
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;
@@ -54,6 +55,9 @@ public class UserSystemController extends BaseController {
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Autowired
private DiscussionsRepository discussionsRepository;
/**
* 首页index
* @param tag
@@ -276,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";
}
//获取设置信息
//获取首页统计
@@ -49,7 +49,9 @@ public class RestMsgController extends BaseController {
"id", user.getId(),
"username", user.getUsername() != null ? user.getUsername() : "",
"discussionsCount", user.getDiscussionsCount() != null ? user.getDiscussionsCount() : 0,
"commentsCount", user.getCommentsCount() != null ? user.getCommentsCount() : 0
"commentsCount", user.getCommentsCount() != null ? user.getCommentsCount() : 0,
"level", user.getLevel() != null ? user.getLevel() : 0,
"score", user.getScore() != null ? user.getScore() : 0
));
}
@@ -82,6 +82,6 @@ public class Discussion implements Serializable {
private Date createTime;
@Column(name = "view_count")
private Integer viewCount;
private Integer viewCount = 0;
}
@@ -76,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() {
@@ -10,6 +10,7 @@ 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> {
@@ -49,4 +50,14 @@ public interface DiscussionsRepository extends JpaRepository<Discussion, Integer
@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);
}
@@ -48,4 +48,17 @@ public interface UsersRepository extends JpaRepository<User, Integer> {
/** 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);
}
@@ -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 -> "新手";
};
}
}
@@ -7,6 +7,7 @@ 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;
@@ -29,9 +30,19 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
@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();
@@ -39,6 +50,10 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
// 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
@@ -46,6 +61,14 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
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 {
@@ -59,6 +82,10 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
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;
}
}
@@ -72,12 +99,28 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
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 {
@@ -90,11 +133,14 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
vote.setCreateTime(new Date());
likeCollectRepository.save(vote);
adjustPost(postId, action, +1);
// Notify post author when liked (not disliked)
// 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;
@@ -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);
}
}
@@ -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;
@@ -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">
<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>
+60 -35
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>
<h3 class="ui dividing header">服务器与运行时</h3>
<div class="ui two stackable cards">
<div class="card">
<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">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>
<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>
<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:#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"><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" style="font-size:1em; color:#374151; margin-bottom:.6em;"><i class="microchip icon"></i>CPU / 内存</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>
<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" 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:#374151; margin-bottom:.6em;"><i class="code branch icon"></i>关键依赖版本</div>
<div class="description">
<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>
+7 -5
View File
@@ -29,20 +29,22 @@
</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;
<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>
+22 -19
View File
@@ -6,22 +6,11 @@
</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">
@@ -44,17 +33,29 @@
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="item" id="userlogin">
<#if username??>
<form class="ui large form" action="/logout" id="logoutForm" method="post" style="display: none;">
<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>
${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" id="userlogin">
<a href="/login" id="loginmodel">登录&nbsp;&nbsp;&amp;&nbsp;&nbsp;注册</a>
</div>
</#if>
</div>
</div>
</div>
@@ -66,6 +67,8 @@
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) {
+42 -76
View File
@@ -187,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>
@@ -199,90 +199,48 @@
<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> 一感谢支持!
<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="statistic">
<div class="value">${authorReplyCount!0}</div>
<div class="label">回复</div>
</div>
</div>
</div>
</div>
<div class="event">
<div class="content">
<div class="summary">
<a>2019</a> 测试内容
</div>
</div>
</div>
</div>
</div>
<div class="extra content">
<button class="ui button">预留按钮</button>
</div>
</div>
<#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>
</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>
<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>
<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>
@@ -329,11 +287,19 @@
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 || '') + '</div>' +
'<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) + '</div>' +
'<div style="margin-top:8px;"><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>'
'&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();
}
+90 -5
View File
@@ -333,8 +333,7 @@
</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>
@@ -387,7 +386,7 @@
</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>
@@ -439,7 +438,7 @@
</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>
@@ -505,7 +504,7 @@
<div class="date">${discussions.startTime}</div>
</div>
<div class="meta">
<a><b>${discussions.username}</b></a>&nbsp;•&nbsp;
<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>
@@ -556,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>
+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>
+93 -2
View File
@@ -11,6 +11,52 @@
.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;">
@@ -39,6 +85,13 @@
加入于 ${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="profile-card">
<div class="ui three column grid" style="text-align:center;">
@@ -51,10 +104,19 @@
<div class="label2">回复</div>
</div>
<div class="column profile-stat">
<div class="number">${(userEntity.level)!1}</div>
<div class="label2">等级</div>
<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>
@@ -148,5 +210,34 @@
</div>
<#include "comm/footer.ftl"/>
<#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" href="/user?type=reply">
<i class="comment icon"></i>回复
</a>
<a class="item" href="/user?type=like">
<i class="heart icon"></i>点赞
</a>
</div>
<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>