From 7b94ee62e8c58253308f36675fc7d2c070741cf3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 15:51:49 +0000 Subject: [PATCH 1/2] feat: add beautiful TeamUp frontend Co-authored-by: BlueOrbit --- src/main/resources/static/app.js | 567 +++++++++++++++++++++++++++ src/main/resources/static/index.html | 133 +++++++ src/main/resources/static/styles.css | 376 ++++++++++++++++++ 3 files changed, 1076 insertions(+) create mode 100644 src/main/resources/static/app.js create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/static/styles.css diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js new file mode 100644 index 0000000..55c9ddd --- /dev/null +++ b/src/main/resources/static/app.js @@ -0,0 +1,567 @@ +(() => { + const STORAGE_TOKEN_KEY = "teamup_token"; + const STORAGE_UID_KEY = "teamup_uid"; + const APPLICATION_STATE = { + WAIT: 0, + ACCEPT: 1, + DECLINE: 2 + }; + + const state = { + token: localStorage.getItem(STORAGE_TOKEN_KEY) || "", + uid: parseUid(localStorage.getItem(STORAGE_UID_KEY)), + teams: [], + selectedTeam: null, + userNameMap: new Map(), + searchKeyword: "" + }; + + const elements = { + sessionHint: document.getElementById("sessionHint"), + refreshBtn: document.getElementById("refreshBtn"), + logoutBtn: document.getElementById("logoutBtn"), + summaryText: document.getElementById("summaryText"), + teamList: document.getElementById("teamList"), + teamDetail: document.getElementById("teamDetail"), + detailTitle: document.getElementById("detailTitle"), + detailMeta: document.getElementById("detailMeta"), + detailContent: document.getElementById("detailContent"), + commentList: document.getElementById("commentList"), + applicationList: document.getElementById("applicationList"), + registerForm: document.getElementById("registerForm"), + loginForm: document.getElementById("loginForm"), + createTeamForm: document.getElementById("createTeamForm"), + searchForm: document.getElementById("searchForm"), + searchInput: document.getElementById("searchInput"), + resetSearchBtn: document.getElementById("resetSearchBtn"), + closeDetailBtn: document.getElementById("closeDetailBtn"), + commentForm: document.getElementById("commentForm"), + applicationForm: document.getElementById("applicationForm"), + toastContainer: document.getElementById("toastContainer") + }; + + function parseUid(raw) { + const value = Number.parseInt(raw || "", 10); + return Number.isFinite(value) ? value : null; + } + + function saveSession(token, uid) { + state.token = token || ""; + state.uid = Number.isFinite(uid) ? uid : null; + if (state.token && state.uid) { + localStorage.setItem(STORAGE_TOKEN_KEY, state.token); + localStorage.setItem(STORAGE_UID_KEY, String(state.uid)); + } else { + localStorage.removeItem(STORAGE_TOKEN_KEY); + localStorage.removeItem(STORAGE_UID_KEY); + } + syncSessionUI(); + } + + function syncSessionUI() { + const loggedIn = Boolean(state.token && state.uid); + elements.sessionHint.textContent = loggedIn + ? `已登录:用户 #${state.uid}` + : "未登录(可浏览队伍、搜索信息)"; + elements.logoutBtn.classList.toggle("hidden", !loggedIn); + toggleFormDisabled(elements.createTeamForm, !loggedIn); + } + + function toggleFormDisabled(form, disabled) { + if (!form) { + return; + } + for (const field of form.elements) { + field.disabled = disabled; + } + } + + function isSuccess(code) { + return typeof code === "number" && code % 10 === 1; + } + + // 统一请求封装:自动带 token、兼容 JSON 与文本错误信息。 + async function api(path, { method = "GET", body, auth = method !== "GET" } = {}) { + const headers = {}; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (auth && state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + try { + const response = await fetch(path, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }); + const contentType = response.headers.get("content-type") || ""; + let payload; + if (contentType.includes("application/json")) { + payload = await response.json(); + } else { + payload = { + code: response.ok ? 200001 : 200000, + data: null, + msg: (await response.text()) || "" + }; + } + if (!response.ok && !payload.msg) { + payload.msg = `请求失败(HTTP ${response.status})`; + } + if (payload && payload.code === 200000 && /(token|unauthorized|expired|bearer)/i.test(payload.msg || "")) { + saveSession("", null); + showToast("登录状态已失效,请重新登录", "error"); + } + return payload; + } catch (error) { + return { + code: 200000, + data: null, + msg: error instanceof Error ? error.message : "网络错误" + }; + } + } + + function parseMembers(raw) { + if (!raw || typeof raw !== "string") { + return []; + } + return raw.split(";").map((item) => item.trim()).filter(Boolean); + } + + function getUserName(uid) { + const key = Number(uid); + return state.userNameMap.get(key) || `用户#${uid ?? "-"}`; + } + + function formatTime(raw) { + if (!raw) { + return "未知时间"; + } + const date = new Date(raw); + if (Number.isNaN(date.getTime())) { + return raw; + } + return date.toLocaleString("zh-CN"); + } + + function applicationStateText(stateValue) { + if (stateValue === APPLICATION_STATE.ACCEPT) { + return "已通过"; + } + if (stateValue === APPLICATION_STATE.DECLINE) { + return "已拒绝"; + } + return "待处理"; + } + + function escapeHtml(input) { + return String(input ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); + } + + function showToast(message, type = "info") { + const toast = document.createElement("div"); + toast.className = `toast ${type}`; + toast.textContent = message; + elements.toastContainer.appendChild(toast); + setTimeout(() => { + toast.remove(); + }, 2600); + } + + async function loadUsers() { + const result = await api("/users", { auth: false }); + if (!isSuccess(result.code) || !Array.isArray(result.data)) { + return; + } + const map = new Map(); + for (const item of result.data) { + if (item && item.user && item.user.id != null) { + map.set(Number(item.user.id), item.user.name || `用户#${item.user.id}`); + } + } + state.userNameMap = map; + } + + async function loadTeams() { + const keyword = state.searchKeyword.trim(); + const result = keyword + ? await api("/info/search", { + method: "POST", + auth: false, + body: { content: keyword } + }) + : await api("/teams", { auth: false }); + + if (!isSuccess(result.code) || !Array.isArray(result.data)) { + state.teams = []; + renderTeamList(); + elements.summaryText.textContent = `加载失败:${result.msg || "请稍后重试"}`; + return; + } + state.teams = result.data; + renderTeamList(); + elements.summaryText.textContent = keyword + ? `搜索 "${keyword}" 共找到 ${state.teams.length} 个队伍` + : `当前共有 ${state.teams.length} 个队伍`; + } + + function renderTeamList() { + if (!state.teams.length) { + elements.teamList.innerHTML = "
暂无数据,试试修改关键词或先创建一个队伍。
"; + return; + } + const loggedIn = Boolean(state.uid && state.token); + const cards = state.teams.map((teamInfo) => { + const team = teamInfo.team || {}; + const info = teamInfo.info || {}; + const memberCount = parseMembers(team.teammates).length; + const creatorName = getUserName(team.creatorId); + const commentCount = Array.isArray(teamInfo.commentList) ? teamInfo.commentList.length : 0; + const applicationCount = Array.isArray(teamInfo.applicationList) ? teamInfo.applicationList.length : 0; + const canApply = loggedIn && Number(team.creatorId) !== Number(state.uid); + return ` +
+
+

${escapeHtml(team.name || "未命名队伍")}

+ ${escapeHtml(info.course || "未填写课程")} +
+

创建者:${escapeHtml(creatorName)} · 成员:${memberCount}/${escapeHtml(info.numberLimit ?? "-")}

+

评论 ${commentCount} 条 · 申请 ${applicationCount} 条

+

${escapeHtml(info.content || "暂无招募描述")}

+
+ + +
+
+ `; + }).join(""); + elements.teamList.innerHTML = cards; + + for (const btn of elements.teamList.querySelectorAll(".js-open-detail")) { + btn.addEventListener("click", () => { + const id = Number(btn.dataset.teamId); + if (Number.isFinite(id)) { + openTeamDetail(id); + } + }); + } + for (const btn of elements.teamList.querySelectorAll(".js-quick-apply")) { + btn.addEventListener("click", async () => { + const id = Number(btn.dataset.teamId); + if (Number.isFinite(id)) { + await quickApply(id); + } + }); + } + } + + async function openTeamDetail(teamId) { + const result = await api(`/teams/${teamId}`, { auth: false }); + if (!isSuccess(result.code) || !result.data) { + showToast(result.msg || "队伍详情加载失败", "error"); + return; + } + state.selectedTeam = result.data; + renderTeamDetail(); + elements.teamDetail.classList.remove("hidden"); + elements.teamDetail.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + function renderTeamDetail() { + const teamInfo = state.selectedTeam; + const team = teamInfo?.team || {}; + const info = teamInfo?.info || {}; + const comments = Array.isArray(teamInfo?.commentList) ? teamInfo.commentList : []; + const applications = Array.isArray(teamInfo?.applicationList) ? teamInfo.applicationList : []; + const isCreator = Number(state.uid) === Number(team.creatorId); + + elements.detailTitle.textContent = team.name || "队伍详情"; + elements.detailMeta.textContent = `课程:${info.course || "未填写"} | 创建者:${getUserName(team.creatorId)} | 成员 ${parseMembers(team.teammates).length}/${info.numberLimit ?? "-"}`; + elements.detailContent.textContent = info.content || "暂无招募描述"; + + elements.commentList.innerHTML = comments.length + ? comments.map((comment) => ` +
  • +

    ${escapeHtml(comment.content || "")}

    + ${escapeHtml(getUserName(comment.senderId))} · ${escapeHtml(formatTime(comment.date))} +
  • + `).join("") + : "
  • 还没有评论,欢迎第一个发言。

  • "; + + const appHtml = applications.length + ? applications.map((item) => { + const stateText = applicationStateText(item.state); + const canHandle = isCreator && Number(item.state) === APPLICATION_STATE.WAIT; + return ` +
  • +

    ${escapeHtml(item.msg || "(无留言)")}

    + 申请人:${escapeHtml(getUserName(item.uid))} · 状态:${escapeHtml(stateText)} + ${canHandle ? ` +
    + + +
    + ` : ""} +
  • + `; + }).join("") + : "
  • 暂无申请。

  • "; + elements.applicationList.innerHTML = appHtml; + + for (const btn of elements.applicationList.querySelectorAll(".js-app-action")) { + btn.addEventListener("click", async () => { + const id = Number(btn.dataset.appId); + if (!Number.isFinite(id)) { + return; + } + const nextState = btn.dataset.action === "accept" + ? APPLICATION_STATE.ACCEPT + : APPLICATION_STATE.DECLINE; + await updateApplicationState(id, nextState); + }); + } + + const canComment = Boolean(state.uid && state.token); + toggleFormDisabled(elements.commentForm, !canComment); + if (canComment) { + elements.commentForm.querySelector("textarea").placeholder = "输入你的建议或问题"; + } else { + elements.commentForm.querySelector("textarea").placeholder = "请先登录后发表评论"; + } + + const hasApplied = applications.some((item) => Number(item.uid) === Number(state.uid) && Number(item.state) !== APPLICATION_STATE.DECLINE); + const canApply = Boolean(state.uid && state.token) && !isCreator && !hasApplied; + toggleFormDisabled(elements.applicationForm, !canApply); + if (isCreator) { + elements.applicationForm.querySelector("textarea").placeholder = "你是队长,可在上方处理申请"; + } else if (hasApplied) { + elements.applicationForm.querySelector("textarea").placeholder = "你已有待处理或已通过的申请"; + } else if (!state.uid) { + elements.applicationForm.querySelector("textarea").placeholder = "请先登录后提交申请"; + } else { + elements.applicationForm.querySelector("textarea").placeholder = "简要说明你的优势与可投入时间"; + } + } + + async function quickApply(teamId) { + if (!(state.uid && state.token)) { + showToast("请先登录后申请入队", "error"); + return; + } + const msg = window.prompt("请输入申请留言:", "我有相关经验,愿意积极参与。"); + if (!msg || !msg.trim()) { + return; + } + const result = await api("/applications", { + method: "POST", + body: { uid: state.uid, tid: teamId, msg: msg.trim() } + }); + if (isSuccess(result.code)) { + showToast("申请已提交", "success"); + await loadTeams(); + if (state.selectedTeam?.team?.id === teamId) { + await openTeamDetail(teamId); + } + return; + } + showToast(result.msg || "申请提交失败", "error"); + } + + async function updateApplicationState(applicationId, nextState) { + const result = await api("/applications", { + method: "PUT", + body: { id: applicationId, state: nextState } + }); + if (isSuccess(result.code)) { + showToast("申请状态已更新", "success"); + const currentTeamId = Number(state.selectedTeam?.team?.id); + await loadTeams(); + if (Number.isFinite(currentTeamId)) { + await openTeamDetail(currentTeamId); + } + return; + } + showToast(result.msg || "申请处理失败", "error"); + } + + elements.registerForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const formData = new FormData(elements.registerForm); + const body = { + name: String(formData.get("name") || "").trim(), + email: String(formData.get("email") || "").trim(), + password: String(formData.get("password") || "").trim() + }; + const result = await api("/users", { method: "POST", auth: false, body }); + if (isSuccess(result.code)) { + showToast("注册成功,请继续登录", "success"); + elements.registerForm.reset(); + await loadUsers(); + return; + } + showToast(result.msg || "注册失败", "error"); + }); + + elements.loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const formData = new FormData(elements.loginForm); + const body = { + email: String(formData.get("email") || "").trim(), + password: String(formData.get("password") || "").trim() + }; + const result = await api("/login", { method: "POST", auth: false, body }); + const payload = result.data || {}; + if (isSuccess(result.code) && payload.token && payload.uid != null) { + saveSession(String(payload.token), Number(payload.uid)); + showToast("登录成功", "success"); + elements.loginForm.reset(); + await loadUsers(); + await loadTeams(); + return; + } + showToast(result.msg || "登录失败", "error"); + }); + + elements.logoutBtn.addEventListener("click", async () => { + await api("/logout", { method: "POST" }); + saveSession("", null); + showToast("已退出登录", "info"); + }); + + elements.createTeamForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!(state.uid && state.token)) { + showToast("请先登录后创建队伍", "error"); + return; + } + const formData = new FormData(elements.createTeamForm); + const body = { + team: { + creatorId: state.uid, + name: String(formData.get("name") || "").trim() + }, + info: { + course: String(formData.get("course") || "").trim(), + numberLimit: Number(formData.get("numberLimit") || 0), + content: String(formData.get("content") || "").trim() + } + }; + const result = await api("/teams", { method: "POST", body }); + if (isSuccess(result.code)) { + showToast("队伍创建成功", "success"); + elements.createTeamForm.reset(); + await loadTeams(); + return; + } + showToast(result.msg || "创建队伍失败", "error"); + }); + + elements.searchForm.addEventListener("submit", async (event) => { + event.preventDefault(); + state.searchKeyword = String(new FormData(elements.searchForm).get("keyword") || "").trim(); + await loadTeams(); + }); + + elements.resetSearchBtn.addEventListener("click", async () => { + state.searchKeyword = ""; + elements.searchInput.value = ""; + await loadTeams(); + }); + + elements.refreshBtn.addEventListener("click", async () => { + await loadUsers(); + await loadTeams(); + showToast("数据已刷新", "info"); + }); + + elements.closeDetailBtn.addEventListener("click", () => { + state.selectedTeam = null; + elements.teamDetail.classList.add("hidden"); + }); + + elements.commentForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const teamId = Number(state.selectedTeam?.team?.id); + if (!Number.isFinite(teamId)) { + showToast("请先选择队伍", "error"); + return; + } + if (!(state.uid && state.token)) { + showToast("请先登录后评论", "error"); + return; + } + const formData = new FormData(elements.commentForm); + const content = String(formData.get("content") || "").trim(); + if (!content) { + showToast("评论内容不能为空", "error"); + return; + } + const result = await api("/comments", { + method: "POST", + body: { + senderId: state.uid, + teamId, + content + } + }); + if (isSuccess(result.code)) { + showToast("评论发布成功", "success"); + elements.commentForm.reset(); + await loadTeams(); + await openTeamDetail(teamId); + return; + } + showToast(result.msg || "评论失败", "error"); + }); + + elements.applicationForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const teamId = Number(state.selectedTeam?.team?.id); + if (!Number.isFinite(teamId)) { + showToast("请先选择队伍", "error"); + return; + } + if (!(state.uid && state.token)) { + showToast("请先登录后申请", "error"); + return; + } + const formData = new FormData(elements.applicationForm); + const msg = String(formData.get("msg") || "").trim(); + if (!msg) { + showToast("申请留言不能为空", "error"); + return; + } + const result = await api("/applications", { + method: "POST", + body: { + uid: state.uid, + tid: teamId, + msg + } + }); + if (isSuccess(result.code)) { + showToast("申请提交成功", "success"); + elements.applicationForm.reset(); + await loadTeams(); + await openTeamDetail(teamId); + return; + } + showToast(result.msg || "申请失败", "error"); + }); + + async function bootstrap() { + syncSessionUI(); + await loadUsers(); + await loadTeams(); + showToast("欢迎来到 TeamUp", "info"); + } + + bootstrap(); +})(); diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..d5132c7 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,133 @@ + + + + + + TeamUp 课程组队平台 + + + + + + +
    +
    +
    + TU +
    +

    TeamUp

    +

    课程组队与协作平台

    +
    +
    +
    + + +
    +
    + +
    + + +
    +
    +
    + + + +
    +
    正在加载队伍数据...
    +
    + +
    + + +
    +
    + +
    + + + diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css new file mode 100644 index 0000000..97fd94a --- /dev/null +++ b/src/main/resources/static/styles.css @@ -0,0 +1,376 @@ +:root { + --bg: #f4f7ff; + --panel: rgba(255, 255, 255, 0.85); + --text: #1c2540; + --muted: #6d7692; + --primary: #3553ff; + --accent: #00a97f; + --danger: #d74848; + --border: #dce3ff; + --shadow: 0 12px 35px rgba(48, 74, 170, 0.15); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Inter", "PingFang SC", "Microsoft YaHei", sans-serif; + color: var(--text); + background: radial-gradient(circle at 20% 0%, #e8edff, #f6f9ff 45%, #f3f6ff 100%); + min-height: 100vh; +} + +.bg-decoration { + position: fixed; + inset: auto -160px -180px auto; + width: 420px; + height: 420px; + border-radius: 50%; + background: radial-gradient(circle, rgba(53, 83, 255, 0.24), rgba(53, 83, 255, 0)); + pointer-events: none; +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + backdrop-filter: blur(8px); + background: rgba(246, 249, 255, 0.8); + border-bottom: 1px solid rgba(220, 227, 255, 0.7); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 44px; + height: 44px; + border-radius: 14px; + background: linear-gradient(135deg, #3553ff, #667eff); + color: #fff; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow); +} + +.brand-text h1 { + margin: 0; + font-size: 1.2rem; +} + +.brand-text p { + margin: 2px 0 0; + color: var(--muted); + font-size: 0.9rem; +} + +.topbar-actions { + display: flex; + gap: 10px; +} + +.layout { + display: grid; + grid-template-columns: 350px 1fr; + gap: 18px; + padding: 20px; +} + +.panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow); + padding: 16px; +} + +.card h2 { + margin: 0 0 12px; + font-size: 1.05rem; +} + +.status-card p { + margin: 0; + color: var(--muted); +} + +.form-grid { + display: grid; + gap: 10px; +} + +.form-grid.compact { + margin-top: 10px; +} + +label { + display: grid; + gap: 6px; + font-size: 0.92rem; + color: var(--muted); +} + +input, +textarea { + border: 1px solid #cdd6fb; + border-radius: 12px; + padding: 10px 12px; + background: #fff; + font: inherit; + color: var(--text); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input:focus, +textarea:focus { + outline: none; + border-color: #7f94ff; + box-shadow: 0 0 0 3px rgba(53, 83, 255, 0.15); +} + +.btn { + border: none; + border-radius: 12px; + padding: 10px 14px; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, opacity 0.2s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; +} + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-accent { + background: var(--accent); + color: #fff; +} + +.btn-danger { + background: var(--danger); + color: #fff; +} + +.btn-ghost { + background: #eaf0ff; + color: #2d49da; +} + +.search-form { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 10px; +} + +.summary { + margin-top: 10px; + color: var(--muted); + font-size: 0.92rem; +} + +.team-list { + display: grid; + gap: 12px; +} + +.team-item { + background: rgba(255, 255, 255, 0.95); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px; + display: grid; + gap: 10px; +} + +.team-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: baseline; +} + +.team-name { + margin: 0; + font-size: 1rem; +} + +.pill { + display: inline-block; + border-radius: 999px; + padding: 4px 10px; + background: #edf2ff; + color: #3653f9; + font-size: 0.78rem; +} + +.team-meta { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.team-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.team-detail .detail-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.detail-meta { + color: var(--muted); + font-size: 0.92rem; + margin-top: 6px; +} + +.detail-content { + margin: 12px 0; + line-height: 1.6; +} + +.detail-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.sub-card { + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px; + background: #fff; +} + +.sub-card h3 { + margin: 0 0 10px; + font-size: 0.98rem; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; + max-height: 320px; + overflow: auto; +} + +.list-item { + border: 1px solid #e1e7ff; + border-radius: 10px; + padding: 9px 10px; + background: #fbfcff; +} + +.list-item p { + margin: 0; + line-height: 1.45; +} + +.list-item small { + display: block; + margin-top: 6px; + color: #7b84a1; +} + +.application-actions { + margin-top: 8px; + display: flex; + gap: 8px; +} + +.hidden { + display: none !important; +} + +.empty { + color: var(--muted); + text-align: center; + padding: 20px; +} + +.toast-container { + position: fixed; + right: 20px; + bottom: 20px; + display: grid; + gap: 8px; + z-index: 30; +} + +.toast { + min-width: 240px; + padding: 10px 12px; + border-radius: 12px; + color: #fff; + box-shadow: var(--shadow); + font-size: 0.92rem; +} + +.toast.info { + background: #3857ff; +} + +.toast.success { + background: #05a479; +} + +.toast.error { + background: #d94a4a; +} + +@media (max-width: 1080px) { + .layout { + grid-template-columns: 1fr; + } + + .detail-columns { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .topbar { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + + .search-form { + grid-template-columns: 1fr; + } +} From 95937e939762f0c25ded6b207efed6f4f09c4daf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 16:19:25 +0000 Subject: [PATCH 2/2] feat: refactor frontend with nested routes and dark theme Co-authored-by: BlueOrbit --- src/main/resources/static/app.js | 567 ------------------ src/main/resources/static/index.html | 118 +--- src/main/resources/static/src/api.js | 70 +++ src/main/resources/static/src/main.js | 83 +++ .../static/src/pages/teams/detailPage.js | 274 +++++++++ .../static/src/pages/teams/editPage.js | 138 +++++ .../static/src/pages/teams/listPage.js | 336 +++++++++++ src/main/resources/static/src/router.js | 75 +++ src/main/resources/static/src/state.js | 72 +++ src/main/resources/static/src/ui/skeleton.js | 36 ++ src/main/resources/static/src/ui/toast.js | 12 + src/main/resources/static/src/utils.js | 39 ++ src/main/resources/static/styles.css | 376 ------------ src/main/resources/static/styles/base.css | 336 +++++++++++ src/main/resources/static/styles/skeleton.css | 57 ++ src/main/resources/static/styles/theme.css | 135 +++++ 16 files changed, 1675 insertions(+), 1049 deletions(-) delete mode 100644 src/main/resources/static/app.js create mode 100644 src/main/resources/static/src/api.js create mode 100644 src/main/resources/static/src/main.js create mode 100644 src/main/resources/static/src/pages/teams/detailPage.js create mode 100644 src/main/resources/static/src/pages/teams/editPage.js create mode 100644 src/main/resources/static/src/pages/teams/listPage.js create mode 100644 src/main/resources/static/src/router.js create mode 100644 src/main/resources/static/src/state.js create mode 100644 src/main/resources/static/src/ui/skeleton.js create mode 100644 src/main/resources/static/src/ui/toast.js create mode 100644 src/main/resources/static/src/utils.js delete mode 100644 src/main/resources/static/styles.css create mode 100644 src/main/resources/static/styles/base.css create mode 100644 src/main/resources/static/styles/skeleton.css create mode 100644 src/main/resources/static/styles/theme.css diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js deleted file mode 100644 index 55c9ddd..0000000 --- a/src/main/resources/static/app.js +++ /dev/null @@ -1,567 +0,0 @@ -(() => { - const STORAGE_TOKEN_KEY = "teamup_token"; - const STORAGE_UID_KEY = "teamup_uid"; - const APPLICATION_STATE = { - WAIT: 0, - ACCEPT: 1, - DECLINE: 2 - }; - - const state = { - token: localStorage.getItem(STORAGE_TOKEN_KEY) || "", - uid: parseUid(localStorage.getItem(STORAGE_UID_KEY)), - teams: [], - selectedTeam: null, - userNameMap: new Map(), - searchKeyword: "" - }; - - const elements = { - sessionHint: document.getElementById("sessionHint"), - refreshBtn: document.getElementById("refreshBtn"), - logoutBtn: document.getElementById("logoutBtn"), - summaryText: document.getElementById("summaryText"), - teamList: document.getElementById("teamList"), - teamDetail: document.getElementById("teamDetail"), - detailTitle: document.getElementById("detailTitle"), - detailMeta: document.getElementById("detailMeta"), - detailContent: document.getElementById("detailContent"), - commentList: document.getElementById("commentList"), - applicationList: document.getElementById("applicationList"), - registerForm: document.getElementById("registerForm"), - loginForm: document.getElementById("loginForm"), - createTeamForm: document.getElementById("createTeamForm"), - searchForm: document.getElementById("searchForm"), - searchInput: document.getElementById("searchInput"), - resetSearchBtn: document.getElementById("resetSearchBtn"), - closeDetailBtn: document.getElementById("closeDetailBtn"), - commentForm: document.getElementById("commentForm"), - applicationForm: document.getElementById("applicationForm"), - toastContainer: document.getElementById("toastContainer") - }; - - function parseUid(raw) { - const value = Number.parseInt(raw || "", 10); - return Number.isFinite(value) ? value : null; - } - - function saveSession(token, uid) { - state.token = token || ""; - state.uid = Number.isFinite(uid) ? uid : null; - if (state.token && state.uid) { - localStorage.setItem(STORAGE_TOKEN_KEY, state.token); - localStorage.setItem(STORAGE_UID_KEY, String(state.uid)); - } else { - localStorage.removeItem(STORAGE_TOKEN_KEY); - localStorage.removeItem(STORAGE_UID_KEY); - } - syncSessionUI(); - } - - function syncSessionUI() { - const loggedIn = Boolean(state.token && state.uid); - elements.sessionHint.textContent = loggedIn - ? `已登录:用户 #${state.uid}` - : "未登录(可浏览队伍、搜索信息)"; - elements.logoutBtn.classList.toggle("hidden", !loggedIn); - toggleFormDisabled(elements.createTeamForm, !loggedIn); - } - - function toggleFormDisabled(form, disabled) { - if (!form) { - return; - } - for (const field of form.elements) { - field.disabled = disabled; - } - } - - function isSuccess(code) { - return typeof code === "number" && code % 10 === 1; - } - - // 统一请求封装:自动带 token、兼容 JSON 与文本错误信息。 - async function api(path, { method = "GET", body, auth = method !== "GET" } = {}) { - const headers = {}; - if (body !== undefined) { - headers["Content-Type"] = "application/json"; - } - if (auth && state.token) { - headers.Authorization = `Bearer ${state.token}`; - } - try { - const response = await fetch(path, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined - }); - const contentType = response.headers.get("content-type") || ""; - let payload; - if (contentType.includes("application/json")) { - payload = await response.json(); - } else { - payload = { - code: response.ok ? 200001 : 200000, - data: null, - msg: (await response.text()) || "" - }; - } - if (!response.ok && !payload.msg) { - payload.msg = `请求失败(HTTP ${response.status})`; - } - if (payload && payload.code === 200000 && /(token|unauthorized|expired|bearer)/i.test(payload.msg || "")) { - saveSession("", null); - showToast("登录状态已失效,请重新登录", "error"); - } - return payload; - } catch (error) { - return { - code: 200000, - data: null, - msg: error instanceof Error ? error.message : "网络错误" - }; - } - } - - function parseMembers(raw) { - if (!raw || typeof raw !== "string") { - return []; - } - return raw.split(";").map((item) => item.trim()).filter(Boolean); - } - - function getUserName(uid) { - const key = Number(uid); - return state.userNameMap.get(key) || `用户#${uid ?? "-"}`; - } - - function formatTime(raw) { - if (!raw) { - return "未知时间"; - } - const date = new Date(raw); - if (Number.isNaN(date.getTime())) { - return raw; - } - return date.toLocaleString("zh-CN"); - } - - function applicationStateText(stateValue) { - if (stateValue === APPLICATION_STATE.ACCEPT) { - return "已通过"; - } - if (stateValue === APPLICATION_STATE.DECLINE) { - return "已拒绝"; - } - return "待处理"; - } - - function escapeHtml(input) { - return String(input ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("\"", """) - .replaceAll("'", "'"); - } - - function showToast(message, type = "info") { - const toast = document.createElement("div"); - toast.className = `toast ${type}`; - toast.textContent = message; - elements.toastContainer.appendChild(toast); - setTimeout(() => { - toast.remove(); - }, 2600); - } - - async function loadUsers() { - const result = await api("/users", { auth: false }); - if (!isSuccess(result.code) || !Array.isArray(result.data)) { - return; - } - const map = new Map(); - for (const item of result.data) { - if (item && item.user && item.user.id != null) { - map.set(Number(item.user.id), item.user.name || `用户#${item.user.id}`); - } - } - state.userNameMap = map; - } - - async function loadTeams() { - const keyword = state.searchKeyword.trim(); - const result = keyword - ? await api("/info/search", { - method: "POST", - auth: false, - body: { content: keyword } - }) - : await api("/teams", { auth: false }); - - if (!isSuccess(result.code) || !Array.isArray(result.data)) { - state.teams = []; - renderTeamList(); - elements.summaryText.textContent = `加载失败:${result.msg || "请稍后重试"}`; - return; - } - state.teams = result.data; - renderTeamList(); - elements.summaryText.textContent = keyword - ? `搜索 "${keyword}" 共找到 ${state.teams.length} 个队伍` - : `当前共有 ${state.teams.length} 个队伍`; - } - - function renderTeamList() { - if (!state.teams.length) { - elements.teamList.innerHTML = "
    暂无数据,试试修改关键词或先创建一个队伍。
    "; - return; - } - const loggedIn = Boolean(state.uid && state.token); - const cards = state.teams.map((teamInfo) => { - const team = teamInfo.team || {}; - const info = teamInfo.info || {}; - const memberCount = parseMembers(team.teammates).length; - const creatorName = getUserName(team.creatorId); - const commentCount = Array.isArray(teamInfo.commentList) ? teamInfo.commentList.length : 0; - const applicationCount = Array.isArray(teamInfo.applicationList) ? teamInfo.applicationList.length : 0; - const canApply = loggedIn && Number(team.creatorId) !== Number(state.uid); - return ` -
    -
    -

    ${escapeHtml(team.name || "未命名队伍")}

    - ${escapeHtml(info.course || "未填写课程")} -
    -

    创建者:${escapeHtml(creatorName)} · 成员:${memberCount}/${escapeHtml(info.numberLimit ?? "-")}

    -

    评论 ${commentCount} 条 · 申请 ${applicationCount} 条

    -

    ${escapeHtml(info.content || "暂无招募描述")}

    -
    - - -
    -
    - `; - }).join(""); - elements.teamList.innerHTML = cards; - - for (const btn of elements.teamList.querySelectorAll(".js-open-detail")) { - btn.addEventListener("click", () => { - const id = Number(btn.dataset.teamId); - if (Number.isFinite(id)) { - openTeamDetail(id); - } - }); - } - for (const btn of elements.teamList.querySelectorAll(".js-quick-apply")) { - btn.addEventListener("click", async () => { - const id = Number(btn.dataset.teamId); - if (Number.isFinite(id)) { - await quickApply(id); - } - }); - } - } - - async function openTeamDetail(teamId) { - const result = await api(`/teams/${teamId}`, { auth: false }); - if (!isSuccess(result.code) || !result.data) { - showToast(result.msg || "队伍详情加载失败", "error"); - return; - } - state.selectedTeam = result.data; - renderTeamDetail(); - elements.teamDetail.classList.remove("hidden"); - elements.teamDetail.scrollIntoView({ behavior: "smooth", block: "start" }); - } - - function renderTeamDetail() { - const teamInfo = state.selectedTeam; - const team = teamInfo?.team || {}; - const info = teamInfo?.info || {}; - const comments = Array.isArray(teamInfo?.commentList) ? teamInfo.commentList : []; - const applications = Array.isArray(teamInfo?.applicationList) ? teamInfo.applicationList : []; - const isCreator = Number(state.uid) === Number(team.creatorId); - - elements.detailTitle.textContent = team.name || "队伍详情"; - elements.detailMeta.textContent = `课程:${info.course || "未填写"} | 创建者:${getUserName(team.creatorId)} | 成员 ${parseMembers(team.teammates).length}/${info.numberLimit ?? "-"}`; - elements.detailContent.textContent = info.content || "暂无招募描述"; - - elements.commentList.innerHTML = comments.length - ? comments.map((comment) => ` -
  • -

    ${escapeHtml(comment.content || "")}

    - ${escapeHtml(getUserName(comment.senderId))} · ${escapeHtml(formatTime(comment.date))} -
  • - `).join("") - : "
  • 还没有评论,欢迎第一个发言。

  • "; - - const appHtml = applications.length - ? applications.map((item) => { - const stateText = applicationStateText(item.state); - const canHandle = isCreator && Number(item.state) === APPLICATION_STATE.WAIT; - return ` -
  • -

    ${escapeHtml(item.msg || "(无留言)")}

    - 申请人:${escapeHtml(getUserName(item.uid))} · 状态:${escapeHtml(stateText)} - ${canHandle ? ` -
    - - -
    - ` : ""} -
  • - `; - }).join("") - : "
  • 暂无申请。

  • "; - elements.applicationList.innerHTML = appHtml; - - for (const btn of elements.applicationList.querySelectorAll(".js-app-action")) { - btn.addEventListener("click", async () => { - const id = Number(btn.dataset.appId); - if (!Number.isFinite(id)) { - return; - } - const nextState = btn.dataset.action === "accept" - ? APPLICATION_STATE.ACCEPT - : APPLICATION_STATE.DECLINE; - await updateApplicationState(id, nextState); - }); - } - - const canComment = Boolean(state.uid && state.token); - toggleFormDisabled(elements.commentForm, !canComment); - if (canComment) { - elements.commentForm.querySelector("textarea").placeholder = "输入你的建议或问题"; - } else { - elements.commentForm.querySelector("textarea").placeholder = "请先登录后发表评论"; - } - - const hasApplied = applications.some((item) => Number(item.uid) === Number(state.uid) && Number(item.state) !== APPLICATION_STATE.DECLINE); - const canApply = Boolean(state.uid && state.token) && !isCreator && !hasApplied; - toggleFormDisabled(elements.applicationForm, !canApply); - if (isCreator) { - elements.applicationForm.querySelector("textarea").placeholder = "你是队长,可在上方处理申请"; - } else if (hasApplied) { - elements.applicationForm.querySelector("textarea").placeholder = "你已有待处理或已通过的申请"; - } else if (!state.uid) { - elements.applicationForm.querySelector("textarea").placeholder = "请先登录后提交申请"; - } else { - elements.applicationForm.querySelector("textarea").placeholder = "简要说明你的优势与可投入时间"; - } - } - - async function quickApply(teamId) { - if (!(state.uid && state.token)) { - showToast("请先登录后申请入队", "error"); - return; - } - const msg = window.prompt("请输入申请留言:", "我有相关经验,愿意积极参与。"); - if (!msg || !msg.trim()) { - return; - } - const result = await api("/applications", { - method: "POST", - body: { uid: state.uid, tid: teamId, msg: msg.trim() } - }); - if (isSuccess(result.code)) { - showToast("申请已提交", "success"); - await loadTeams(); - if (state.selectedTeam?.team?.id === teamId) { - await openTeamDetail(teamId); - } - return; - } - showToast(result.msg || "申请提交失败", "error"); - } - - async function updateApplicationState(applicationId, nextState) { - const result = await api("/applications", { - method: "PUT", - body: { id: applicationId, state: nextState } - }); - if (isSuccess(result.code)) { - showToast("申请状态已更新", "success"); - const currentTeamId = Number(state.selectedTeam?.team?.id); - await loadTeams(); - if (Number.isFinite(currentTeamId)) { - await openTeamDetail(currentTeamId); - } - return; - } - showToast(result.msg || "申请处理失败", "error"); - } - - elements.registerForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const formData = new FormData(elements.registerForm); - const body = { - name: String(formData.get("name") || "").trim(), - email: String(formData.get("email") || "").trim(), - password: String(formData.get("password") || "").trim() - }; - const result = await api("/users", { method: "POST", auth: false, body }); - if (isSuccess(result.code)) { - showToast("注册成功,请继续登录", "success"); - elements.registerForm.reset(); - await loadUsers(); - return; - } - showToast(result.msg || "注册失败", "error"); - }); - - elements.loginForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const formData = new FormData(elements.loginForm); - const body = { - email: String(formData.get("email") || "").trim(), - password: String(formData.get("password") || "").trim() - }; - const result = await api("/login", { method: "POST", auth: false, body }); - const payload = result.data || {}; - if (isSuccess(result.code) && payload.token && payload.uid != null) { - saveSession(String(payload.token), Number(payload.uid)); - showToast("登录成功", "success"); - elements.loginForm.reset(); - await loadUsers(); - await loadTeams(); - return; - } - showToast(result.msg || "登录失败", "error"); - }); - - elements.logoutBtn.addEventListener("click", async () => { - await api("/logout", { method: "POST" }); - saveSession("", null); - showToast("已退出登录", "info"); - }); - - elements.createTeamForm.addEventListener("submit", async (event) => { - event.preventDefault(); - if (!(state.uid && state.token)) { - showToast("请先登录后创建队伍", "error"); - return; - } - const formData = new FormData(elements.createTeamForm); - const body = { - team: { - creatorId: state.uid, - name: String(formData.get("name") || "").trim() - }, - info: { - course: String(formData.get("course") || "").trim(), - numberLimit: Number(formData.get("numberLimit") || 0), - content: String(formData.get("content") || "").trim() - } - }; - const result = await api("/teams", { method: "POST", body }); - if (isSuccess(result.code)) { - showToast("队伍创建成功", "success"); - elements.createTeamForm.reset(); - await loadTeams(); - return; - } - showToast(result.msg || "创建队伍失败", "error"); - }); - - elements.searchForm.addEventListener("submit", async (event) => { - event.preventDefault(); - state.searchKeyword = String(new FormData(elements.searchForm).get("keyword") || "").trim(); - await loadTeams(); - }); - - elements.resetSearchBtn.addEventListener("click", async () => { - state.searchKeyword = ""; - elements.searchInput.value = ""; - await loadTeams(); - }); - - elements.refreshBtn.addEventListener("click", async () => { - await loadUsers(); - await loadTeams(); - showToast("数据已刷新", "info"); - }); - - elements.closeDetailBtn.addEventListener("click", () => { - state.selectedTeam = null; - elements.teamDetail.classList.add("hidden"); - }); - - elements.commentForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const teamId = Number(state.selectedTeam?.team?.id); - if (!Number.isFinite(teamId)) { - showToast("请先选择队伍", "error"); - return; - } - if (!(state.uid && state.token)) { - showToast("请先登录后评论", "error"); - return; - } - const formData = new FormData(elements.commentForm); - const content = String(formData.get("content") || "").trim(); - if (!content) { - showToast("评论内容不能为空", "error"); - return; - } - const result = await api("/comments", { - method: "POST", - body: { - senderId: state.uid, - teamId, - content - } - }); - if (isSuccess(result.code)) { - showToast("评论发布成功", "success"); - elements.commentForm.reset(); - await loadTeams(); - await openTeamDetail(teamId); - return; - } - showToast(result.msg || "评论失败", "error"); - }); - - elements.applicationForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const teamId = Number(state.selectedTeam?.team?.id); - if (!Number.isFinite(teamId)) { - showToast("请先选择队伍", "error"); - return; - } - if (!(state.uid && state.token)) { - showToast("请先登录后申请", "error"); - return; - } - const formData = new FormData(elements.applicationForm); - const msg = String(formData.get("msg") || "").trim(); - if (!msg) { - showToast("申请留言不能为空", "error"); - return; - } - const result = await api("/applications", { - method: "POST", - body: { - uid: state.uid, - tid: teamId, - msg - } - }); - if (isSuccess(result.code)) { - showToast("申请提交成功", "success"); - elements.applicationForm.reset(); - await loadTeams(); - await openTeamDetail(teamId); - return; - } - showToast(result.msg || "申请失败", "error"); - }); - - async function bootstrap() { - syncSessionUI(); - await loadUsers(); - await loadTeams(); - showToast("欢迎来到 TeamUp", "info"); - } - - bootstrap(); -})(); diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index d5132c7..fe64698 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -7,127 +7,33 @@ - + + + - +
    TU

    TeamUp

    -

    课程组队与协作平台

    +

    队伍广场

    +
    +

    游客模式:可浏览、搜索

    +
    - + +
    -
    - - -
    -
    -
    - - - -
    -
    正在加载队伍数据...
    -
    - -
    - - -
    -
    +
    - + diff --git a/src/main/resources/static/src/api.js b/src/main/resources/static/src/api.js new file mode 100644 index 0000000..101b718 --- /dev/null +++ b/src/main/resources/static/src/api.js @@ -0,0 +1,70 @@ +import { clearSession, isLoggedIn, setUserMap, state } from "./state.js"; +import { isSuccess } from "./utils.js"; + +export async function request(path, { method = "GET", body, auth = method !== "GET" } = {}) { + const headers = {}; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (auth && isLoggedIn()) { + headers.Authorization = `Bearer ${state.token}`; + } + try { + const response = await fetch(path, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }); + const contentType = response.headers.get("content-type") || ""; + let payload; + if (contentType.includes("application/json")) { + payload = await response.json(); + } else { + payload = { + code: response.ok ? 200001 : 200000, + data: null, + msg: (await response.text()) || "" + }; + } + if (!response.ok && !payload.msg) { + payload.msg = `请求失败(HTTP ${response.status})`; + } + if (payload?.code === 200000 && /(token|bearer|unauthorized|expired)/i.test(payload.msg || "")) { + clearSession(); + } + return payload; + } catch (error) { + return { + code: 200000, + data: null, + msg: error instanceof Error ? error.message : "网络错误" + }; + } +} + +export async function loadUsers() { + const result = await request("/users", { auth: false }); + if (!isSuccess(result.code) || !Array.isArray(result.data)) { + return result; + } + const map = new Map(); + for (const item of result.data) { + if (item?.user?.id != null) { + map.set(Number(item.user.id), item.user.name || `用户#${item.user.id}`); + } + } + setUserMap(map); + return result; +} + +export async function loadTeams(keyword) { + const value = String(keyword || "").trim(); + if (value) { + return request("/info/search", { + method: "POST", + auth: false, + body: { content: value } + }); + } + return request("/teams", { auth: false }); +} diff --git a/src/main/resources/static/src/main.js b/src/main/resources/static/src/main.js new file mode 100644 index 0000000..3cbc2aa --- /dev/null +++ b/src/main/resources/static/src/main.js @@ -0,0 +1,83 @@ +import { request } from "./api.js"; +import { clearSession, getUserName, isLoggedIn, state, toggleTheme } from "./state.js"; +import { createRouter } from "./router.js"; +import { showToast } from "./ui/toast.js"; +import { renderTeamDetailPage } from "./pages/teams/detailPage.js"; +import { renderTeamEditPage } from "./pages/teams/editPage.js"; +import { renderTeamsListPage } from "./pages/teams/listPage.js"; + +const routeLabelEl = document.getElementById("routeLabel"); +const sessionHintEl = document.getElementById("sessionHint"); +const themeBtnEl = document.getElementById("themeBtn"); +const refreshBtnEl = document.getElementById("refreshBtn"); +const logoutBtnEl = document.getElementById("logoutBtn"); +const app = document.getElementById("app"); + +function applyTheme() { + document.body.dataset.theme = state.theme; + themeBtnEl.textContent = state.theme === "dark" ? "切换浅色" : "切换暗色"; +} + +function updateSessionHint() { + if (!isLoggedIn()) { + sessionHintEl.textContent = "游客模式:可浏览、搜索"; + logoutBtnEl.classList.add("hidden"); + return; + } + sessionHintEl.textContent = `已登录:${getUserName(state.uid)}(UID: ${state.uid})`; + logoutBtnEl.classList.remove("hidden"); +} + +const router = createRouter({ + app, + routes: [ + { + pattern: /^\/teams$/, + label: "队伍广场", + render: renderTeamsListPage + }, + { + pattern: /^\/teams\/(\d+)\/edit$/, + label: "编辑队伍", + mapParams: (match) => ({ teamId: Number(match[1]) }), + render: renderTeamEditPage + }, + { + pattern: /^\/teams\/(\d+)$/, + label: "队伍详情", + mapParams: (match) => ({ teamId: Number(match[1]) }), + render: renderTeamDetailPage + } + ], + onRouteResolved: (label) => { + routeLabelEl.textContent = label; + } +}); + +document.addEventListener("teamup:session", () => { + updateSessionHint(); +}); + +document.addEventListener("teamup:theme", () => { + applyTheme(); +}); + +themeBtnEl.addEventListener("click", () => { + toggleTheme(); +}); + +refreshBtnEl.addEventListener("click", () => { + router.reload(); + showToast("已刷新当前页面", "info"); +}); + +logoutBtnEl.addEventListener("click", async () => { + await request("/logout", { method: "POST" }); + clearSession(); + showToast("已退出登录", "info"); + router.navigate("/teams"); +}); + +applyTheme(); +updateSessionHint(); +router.start(); diff --git a/src/main/resources/static/src/pages/teams/detailPage.js b/src/main/resources/static/src/pages/teams/detailPage.js new file mode 100644 index 0000000..a3f8d2e --- /dev/null +++ b/src/main/resources/static/src/pages/teams/detailPage.js @@ -0,0 +1,274 @@ +import { loadUsers, request } from "../../api.js"; +import { getUserName, isLoggedIn, parseMembers, state } from "../../state.js"; +import { APPLICATION_STATE, escapeHtml, formatTime, isSuccess, stateText } from "../../utils.js"; +import { detailSkeleton } from "../../ui/skeleton.js"; +import { showToast } from "../../ui/toast.js"; + +function permissionHints(teamInfo) { + const team = teamInfo.team || {}; + const applications = Array.isArray(teamInfo.applicationList) ? teamInfo.applicationList : []; + const members = parseMembers(team.teammates); + const isCreator = Number(team.creatorId) === Number(state.uid); + const isMember = members.includes(Number(state.uid)); + const myApplication = applications.find((item) => Number(item.uid) === Number(state.uid) && Number(item.state) !== APPLICATION_STATE.DECLINE); + if (!isLoggedIn()) { + return { + commentHint: "游客不可评论,请先登录。", + applyHint: "游客不可申请,请先登录。", + canComment: false, + canApply: false, + isCreator + }; + } + if (isCreator) { + return { + commentHint: "你是队长,可以发表评论并审批申请。", + applyHint: "你是队长,不需要申请本队伍。", + canComment: true, + canApply: false, + isCreator + }; + } + if (isMember) { + return { + commentHint: "你是队伍成员,可发表评论。", + applyHint: "你已在队伍中,无需重复申请。", + canComment: true, + canApply: false, + isCreator + }; + } + if (myApplication) { + return { + commentHint: "登录后可评论。", + applyHint: "你已有待处理或已通过申请,暂不可重复提交。", + canComment: true, + canApply: false, + isCreator + }; + } + return { + commentHint: "登录用户可发表评论。", + applyHint: "你可以提交入队申请。", + canComment: true, + canApply: true, + isCreator + }; +} + +function renderTemplate(teamInfo) { + const team = teamInfo.team || {}; + const info = teamInfo.info || {}; + const comments = Array.isArray(teamInfo.commentList) ? teamInfo.commentList : []; + const applications = Array.isArray(teamInfo.applicationList) ? teamInfo.applicationList : []; + const members = parseMembers(team.teammates); + const hints = permissionHints(teamInfo); + + const commentsHtml = comments.length + ? comments.map((item) => ` +
  • +

    ${escapeHtml(item.content || "")}

    + ${escapeHtml(getUserName(item.senderId))} · ${escapeHtml(formatTime(item.date))} +
  • + `).join("") + : "
  • 暂无评论,欢迎留言。

  • "; + + const applicationsHtml = applications.length + ? applications.map((item) => { + const canHandle = hints.isCreator && Number(item.state) === APPLICATION_STATE.WAIT; + return ` +
  • +

    ${escapeHtml(item.msg || "(无留言)")}

    + 申请人:${escapeHtml(getUserName(item.uid))} · 状态:${escapeHtml(stateText(item.state))} + ${canHandle ? ` +
    + + +
    + ` : ""} +
  • + `; + }).join("") + : "
  • 暂无申请。

  • "; + + return ` +
    +
    + + ${hints.isCreator ? `` : ""} + ${hints.isCreator ? `` : ""} +
    +

    ${escapeHtml(team.name || "未命名队伍")}

    +

    课程:${escapeHtml(info.course || "未填写")} | 创建者:${escapeHtml(getUserName(team.creatorId))} | 成员:${members.length}/${escapeHtml(info.numberLimit ?? "-")}

    +

    ${escapeHtml(info.content || "暂无招募描述")}

    +
    +

    权限提示

    +

    评论权限:${escapeHtml(hints.commentHint)}

    +

    申请权限:${escapeHtml(hints.applyHint)}

    +
    +
    +
    +

    评论区

    +
      ${commentsHtml}
    +
    + + +
    +
    +
    +

    入队申请

    +
      ${applicationsHtml}
    +
    + + +
    +
    +
    +
    + `; +} + +function toggleFormDisabled(form, disabled) { + if (!form) { + return; + } + for (const field of form.elements) { + field.disabled = disabled; + } +} + +export async function renderTeamDetailPage({ app, params, navigate, signal }) { + const teamId = Number(params.teamId); + if (!Number.isFinite(teamId)) { + app.innerHTML = "
    参数错误:队伍 ID 无效。
    "; + return; + } + + const render = async () => { + app.innerHTML = detailSkeleton(); + await loadUsers(); + if (signal.aborted) { + return; + } + const result = await request(`/teams/${teamId}`, { auth: false }); + if (signal.aborted) { + return; + } + if (!isSuccess(result.code) || !result.data) { + app.innerHTML = ` +
    +

    加载失败

    +

    ${escapeHtml(result.msg || "队伍详情不存在或已删除。")}

    + +
    + `; + app.querySelector("#goBackBtn")?.addEventListener("click", () => navigate("/teams")); + return; + } + const teamInfo = result.data; + const hints = permissionHints(teamInfo); + app.innerHTML = renderTemplate(teamInfo); + + const commentForm = app.querySelector("#commentForm"); + const applyForm = app.querySelector("#applyForm"); + toggleFormDisabled(commentForm, !hints.canComment); + toggleFormDisabled(applyForm, !hints.canApply); + + app.querySelector("#backBtn")?.addEventListener("click", () => navigate("/teams")); + app.querySelector("#editBtn")?.addEventListener("click", () => navigate(`/teams/${teamId}/edit`)); + app.querySelector("#deleteBtn")?.addEventListener("click", async () => { + if (!window.confirm("确认删除该队伍吗?")) { + return; + } + const deleteResult = await request(`/teams/${teamId}`, { method: "DELETE" }); + if (isSuccess(deleteResult.code)) { + showToast("队伍删除成功", "success"); + navigate("/teams"); + } else { + showToast(deleteResult.msg || "删除失败", "error"); + } + }); + + commentForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!isLoggedIn()) { + showToast("请先登录", "error"); + return; + } + const content = String(new FormData(commentForm).get("content") || "").trim(); + if (!content) { + showToast("评论内容不能为空", "error"); + return; + } + const commentResult = await request("/comments", { + method: "POST", + body: { + senderId: state.uid, + teamId, + content + } + }); + if (isSuccess(commentResult.code)) { + showToast("评论成功", "success"); + await render(); + } else { + showToast(commentResult.msg || "评论失败", "error"); + } + }); + + applyForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!isLoggedIn()) { + showToast("请先登录", "error"); + return; + } + const msg = String(new FormData(applyForm).get("msg") || "").trim(); + if (!msg) { + showToast("申请留言不能为空", "error"); + return; + } + const applyResult = await request("/applications", { + method: "POST", + body: { + uid: state.uid, + tid: teamId, + msg + } + }); + if (isSuccess(applyResult.code)) { + showToast("申请已提交", "success"); + await render(); + } else { + showToast(applyResult.msg || "申请失败", "error"); + } + }); + + for (const button of app.querySelectorAll(".js-app-action")) { + button.addEventListener("click", async () => { + const appId = Number(button.dataset.appId); + if (!Number.isFinite(appId)) { + return; + } + const nextState = button.dataset.action === "accept" + ? APPLICATION_STATE.ACCEPT + : APPLICATION_STATE.DECLINE; + const updateResult = await request("/applications", { + method: "PUT", + body: { id: appId, state: nextState } + }); + if (isSuccess(updateResult.code)) { + showToast("申请状态更新成功", "success"); + await render(); + } else { + showToast(updateResult.msg || "申请处理失败", "error"); + } + }); + } + }; + + await render(); +} diff --git a/src/main/resources/static/src/pages/teams/editPage.js b/src/main/resources/static/src/pages/teams/editPage.js new file mode 100644 index 0000000..0fd8d32 --- /dev/null +++ b/src/main/resources/static/src/pages/teams/editPage.js @@ -0,0 +1,138 @@ +import { loadUsers, request } from "../../api.js"; +import { getUserName, isLoggedIn, parseMembers, state } from "../../state.js"; +import { escapeHtml, isSuccess } from "../../utils.js"; +import { detailSkeleton } from "../../ui/skeleton.js"; +import { showToast } from "../../ui/toast.js"; + +function editTemplate(teamInfo) { + const team = teamInfo.team || {}; + const info = teamInfo.info || {}; + const members = parseMembers(team.teammates); + return ` +
    +
    + + +
    +

    编辑队伍:${escapeHtml(team.name || "")}

    +

    创建者:${escapeHtml(getUserName(team.creatorId))} | 当前成员:${members.length}

    +
    + + + + +
    + + +
    +
    +
    + `; +} + +export async function renderTeamEditPage({ app, params, navigate, signal }) { + const teamId = Number(params.teamId); + if (!Number.isFinite(teamId)) { + app.innerHTML = "
    参数错误:队伍 ID 无效。
    "; + return; + } + if (!isLoggedIn()) { + app.innerHTML = ` +
    +

    无权限

    +

    请先登录后编辑队伍。

    + +
    + `; + app.querySelector("#goListBtn")?.addEventListener("click", () => navigate("/teams")); + return; + } + + app.innerHTML = detailSkeleton(); + await loadUsers(); + if (signal.aborted) { + return; + } + const result = await request(`/teams/${teamId}`, { auth: false }); + if (signal.aborted) { + return; + } + if (!isSuccess(result.code) || !result.data) { + app.innerHTML = ` +
    +

    加载失败

    +

    ${escapeHtml(result.msg || "队伍不存在。")}

    + +
    + `; + app.querySelector("#backListBtn")?.addEventListener("click", () => navigate("/teams")); + return; + } + const teamInfo = result.data; + const team = teamInfo.team || {}; + if (Number(team.creatorId) !== Number(state.uid)) { + app.innerHTML = ` +
    +

    无权限

    +

    仅队伍创建者可编辑和删除队伍。

    + +
    + `; + app.querySelector("#goDetailBtn")?.addEventListener("click", () => navigate(`/teams/${teamId}`)); + return; + } + + app.innerHTML = editTemplate(teamInfo); + + const form = app.querySelector("#editTeamForm"); + const deleteBtn = app.querySelector("#deleteTeamBtn"); + + app.querySelector("#backDetailBtn")?.addEventListener("click", () => navigate(`/teams/${teamId}`)); + app.querySelector("#cancelBtn")?.addEventListener("click", () => navigate(`/teams/${teamId}`)); + + form?.addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(form); + const updateResult = await request("/teams", { + method: "PUT", + body: { + team: { + id: teamId, + name: String(data.get("name") || "").trim() + }, + info: { + course: String(data.get("course") || "").trim(), + numberLimit: Number(data.get("numberLimit") || 0), + content: String(data.get("content") || "").trim() + } + } + }); + if (isSuccess(updateResult.code)) { + showToast("队伍更新成功", "success"); + navigate(`/teams/${teamId}`); + } else { + showToast(updateResult.msg || "更新失败", "error"); + } + }); + + deleteBtn?.addEventListener("click", async () => { + if (!window.confirm("确认删除该队伍吗?该操作不可恢复。")) { + return; + } + const deleteResult = await request(`/teams/${teamId}`, { method: "DELETE" }); + if (isSuccess(deleteResult.code)) { + showToast("队伍已删除", "success"); + navigate("/teams"); + } else { + showToast(deleteResult.msg || "删除失败", "error"); + } + }); +} diff --git a/src/main/resources/static/src/pages/teams/listPage.js b/src/main/resources/static/src/pages/teams/listPage.js new file mode 100644 index 0000000..9bbdb49 --- /dev/null +++ b/src/main/resources/static/src/pages/teams/listPage.js @@ -0,0 +1,336 @@ +import { loadTeams, loadUsers, request } from "../../api.js"; +import { getUserName, isLoggedIn, parseMembers, setSearchKeyword, setSession, state } from "../../state.js"; +import { APPLICATION_STATE, escapeHtml, isSuccess } from "../../utils.js"; +import { teamListSkeleton } from "../../ui/skeleton.js"; +import { showToast } from "../../ui/toast.js"; + +function permissionText(teamInfo) { + const team = teamInfo.team || {}; + const applications = Array.isArray(teamInfo.applicationList) ? teamInfo.applicationList : []; + const memberIds = parseMembers(team.teammates); + if (!isLoggedIn()) { + return "游客:登录后可申请和评论"; + } + if (Number(team.creatorId) === Number(state.uid)) { + return "队长:可编辑、删除、审批申请"; + } + if (memberIds.includes(Number(state.uid))) { + return "成员:可参与评论与交流"; + } + const myApplication = applications.find((item) => Number(item.uid) === Number(state.uid)); + if (myApplication?.state === APPLICATION_STATE.ACCEPT) { + return "你已通过申请并加入队伍"; + } + if (myApplication && Number(myApplication.state) !== APPLICATION_STATE.DECLINE) { + return "你已申请,等待队长审批"; + } + return "可提交申请加入队伍"; +} + +function canApply(teamInfo) { + if (!isLoggedIn()) { + return false; + } + const team = teamInfo.team || {}; + if (Number(team.creatorId) === Number(state.uid)) { + return false; + } + const memberIds = parseMembers(team.teammates); + if (memberIds.includes(Number(state.uid))) { + return false; + } + const applications = Array.isArray(teamInfo.applicationList) ? teamInfo.applicationList : []; + return !applications.some((item) => Number(item.uid) === Number(state.uid) && Number(item.state) !== APPLICATION_STATE.DECLINE); +} + +function roleHintText() { + if (!isLoggedIn()) { + return "当前为游客模式:可浏览、搜索、查看详情。登录后可创建队伍、发表评论和提交申请。"; + } + const name = getUserName(state.uid); + return `当前登录:${name}(UID: ${state.uid})。你可创建队伍,并在自己创建的队伍中进行编辑/删除/审批。`; +} + +function template() { + return ` +
    + +
    +
    +
    + + + +
    +

    正在加载队伍数据...

    +
    +
    +
    +
    + `; +} + +export async function renderTeamsListPage({ app, navigate, signal }) { + app.innerHTML = template(); + + const roleHint = app.querySelector("#roleHint"); + const createTeamHint = app.querySelector("#createTeamHint"); + const registerForm = app.querySelector("#registerForm"); + const loginForm = app.querySelector("#loginForm"); + const createTeamForm = app.querySelector("#createTeamForm"); + const searchForm = app.querySelector("#searchForm"); + const searchInput = app.querySelector("#searchInput"); + const resetSearchBtn = app.querySelector("#resetSearchBtn"); + const summaryText = app.querySelector("#summaryText"); + const teamList = app.querySelector("#teamList"); + + searchInput.value = state.searchKeyword; + + const updateAuthHints = () => { + roleHint.textContent = roleHintText(); + const createDisabled = !isLoggedIn(); + for (const field of createTeamForm.elements) { + field.disabled = createDisabled; + } + createTeamHint.textContent = createDisabled + ? "请先登录后创建队伍" + : "你创建的队伍会自动把你加入成员列表"; + }; + + const renderList = (teamInfos) => { + if (!teamInfos.length) { + teamList.innerHTML = "
    暂无队伍,试试切换关键词或创建一个新队伍。
    "; + return; + } + const html = teamInfos.map((teamInfo) => { + const team = teamInfo.team || {}; + const info = teamInfo.info || {}; + const members = parseMembers(team.teammates); + const applications = Array.isArray(teamInfo.applicationList) ? teamInfo.applicationList : []; + const comments = Array.isArray(teamInfo.commentList) ? teamInfo.commentList : []; + const isCreator = Number(team.creatorId) === Number(state.uid); + const applyEnabled = canApply(teamInfo); + return ` +
    +
    +

    ${escapeHtml(team.name || "未命名队伍")}

    + ${escapeHtml(info.course || "未填写课程")} +
    +

    创建者:${escapeHtml(getUserName(team.creatorId))} | 成员:${members.length}/${escapeHtml(info.numberLimit ?? "-")}

    +

    ${escapeHtml(info.content || "暂无招募描述")}

    +

    ${escapeHtml(permissionText(teamInfo))}

    +

    评论 ${comments.length} 条 | 申请 ${applications.length} 条

    +
    + + + ${isCreator ? `` : ""} + ${isCreator ? `` : ""} +
    +
    + `; + }).join(""); + teamList.innerHTML = `
    ${html}
    `; + + for (const btn of teamList.querySelectorAll(".js-view")) { + btn.addEventListener("click", () => navigate(`/teams/${btn.dataset.teamId}`)); + } + for (const btn of teamList.querySelectorAll(".js-edit")) { + btn.addEventListener("click", () => navigate(`/teams/${btn.dataset.teamId}/edit`)); + } + for (const btn of teamList.querySelectorAll(".js-apply")) { + btn.addEventListener("click", async () => { + if (!isLoggedIn()) { + showToast("请先登录后申请", "error"); + return; + } + const teamId = Number(btn.dataset.teamId); + const msg = window.prompt("请输入申请留言:", "我有相关经验,愿意积极参与。"); + if (!msg || !msg.trim()) { + return; + } + const result = await request("/applications", { + method: "POST", + body: { uid: state.uid, tid: teamId, msg: msg.trim() } + }); + if (isSuccess(result.code)) { + showToast("申请已提交", "success"); + await fetchAndRender(); + } else { + showToast(result.msg || "申请失败", "error"); + } + }); + } + for (const btn of teamList.querySelectorAll(".js-delete")) { + btn.addEventListener("click", async () => { + const teamId = Number(btn.dataset.teamId); + if (!window.confirm("确认删除该队伍吗?此操作不可撤销。")) { + return; + } + const result = await request(`/teams/${teamId}`, { method: "DELETE" }); + if (isSuccess(result.code)) { + showToast("队伍已删除", "success"); + await fetchAndRender(); + } else { + showToast(result.msg || "删除失败", "error"); + } + }); + } + }; + + const fetchAndRender = async () => { + summaryText.textContent = "正在加载队伍数据..."; + teamList.innerHTML = teamListSkeleton(4); + await loadUsers(); + if (signal.aborted) { + return; + } + const result = await loadTeams(state.searchKeyword); + if (signal.aborted) { + return; + } + if (!isSuccess(result.code) || !Array.isArray(result.data)) { + summaryText.textContent = `加载失败:${result.msg || "请稍后重试"}`; + teamList.innerHTML = "
    数据加载失败,请稍后刷新。
    "; + return; + } + renderList(result.data); + summaryText.textContent = state.searchKeyword + ? `搜索 "${state.searchKeyword}" 共找到 ${result.data.length} 个队伍` + : `当前共有 ${result.data.length} 个队伍`; + }; + + registerForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(registerForm); + const body = { + name: String(data.get("name") || "").trim(), + email: String(data.get("email") || "").trim(), + password: String(data.get("password") || "").trim() + }; + const result = await request("/users", { method: "POST", auth: false, body }); + if (isSuccess(result.code)) { + showToast("注册成功,请登录", "success"); + registerForm.reset(); + await loadUsers(); + updateAuthHints(); + } else { + showToast(result.msg || "注册失败", "error"); + } + }); + + loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(loginForm); + const body = { + email: String(data.get("email") || "").trim(), + password: String(data.get("password") || "").trim() + }; + const result = await request("/login", { method: "POST", auth: false, body }); + if (isSuccess(result.code) && result.data?.token && result.data?.uid != null) { + setSession(String(result.data.token), Number(result.data.uid)); + showToast("登录成功", "success"); + loginForm.reset(); + updateAuthHints(); + await fetchAndRender(); + } else { + showToast(result.msg || "登录失败", "error"); + } + }); + + createTeamForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!isLoggedIn()) { + showToast("请先登录", "error"); + return; + } + const data = new FormData(createTeamForm); + const result = await request("/teams", { + method: "POST", + body: { + team: { + creatorId: state.uid, + name: String(data.get("name") || "").trim() + }, + info: { + course: String(data.get("course") || "").trim(), + numberLimit: Number(data.get("numberLimit") || 0), + content: String(data.get("content") || "").trim() + } + } + }); + if (isSuccess(result.code)) { + showToast("队伍创建成功", "success"); + createTeamForm.reset(); + await fetchAndRender(); + } else { + showToast(result.msg || "创建队伍失败", "error"); + } + }); + + searchForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const keyword = String(new FormData(searchForm).get("keyword") || ""); + setSearchKeyword(keyword); + await fetchAndRender(); + }); + + resetSearchBtn.addEventListener("click", async () => { + setSearchKeyword(""); + searchInput.value = ""; + await fetchAndRender(); + }); + + updateAuthHints(); + await fetchAndRender(); +} diff --git a/src/main/resources/static/src/router.js b/src/main/resources/static/src/router.js new file mode 100644 index 0000000..70afe0a --- /dev/null +++ b/src/main/resources/static/src/router.js @@ -0,0 +1,75 @@ +function normalizePath(raw) { + const value = (raw || "").trim(); + if (!value) { + return "/teams"; + } + const [path] = value.split("?"); + const normalized = path.startsWith("/") ? path : `/${path}`; + if (normalized.length > 1 && normalized.endsWith("/")) { + return normalized.slice(0, -1); + } + return normalized; +} + +export function createRouter({ app, routes, onRouteResolved }) { + let currentController = null; + + const navigate = (path) => { + const target = normalizePath(path); + if (location.hash === `#${target}`) { + handleRoute(); + return; + } + location.hash = `#${target}`; + }; + + const reload = () => { + handleRoute(); + }; + + const matchRoute = (path) => { + for (const route of routes) { + const match = path.match(route.pattern); + if (match) { + const params = route.mapParams ? route.mapParams(match) : {}; + return { route, params }; + } + } + return null; + }; + + const handleRoute = async () => { + const path = normalizePath(location.hash.replace(/^#/, "")); + const matched = matchRoute(path); + if (!matched) { + navigate("/teams"); + return; + } + if (currentController) { + currentController.abort(); + } + currentController = new AbortController(); + onRouteResolved(matched.route.label || "TeamUp"); + await matched.route.render({ + app, + params: matched.params, + navigate, + reload, + signal: currentController.signal + }); + }; + + window.addEventListener("hashchange", handleRoute); + + return { + start() { + if (!location.hash) { + navigate("/teams"); + } else { + handleRoute(); + } + }, + navigate, + reload + }; +} diff --git a/src/main/resources/static/src/state.js b/src/main/resources/static/src/state.js new file mode 100644 index 0000000..7b253c6 --- /dev/null +++ b/src/main/resources/static/src/state.js @@ -0,0 +1,72 @@ +const TOKEN_KEY = "teamup_token"; +const UID_KEY = "teamup_uid"; +const THEME_KEY = "teamup_theme"; + +const initialTheme = localStorage.getItem(THEME_KEY) || "light"; +const initialUid = Number.parseInt(localStorage.getItem(UID_KEY) || "", 10); + +export const state = { + token: localStorage.getItem(TOKEN_KEY) || "", + uid: Number.isFinite(initialUid) ? initialUid : null, + theme: initialTheme === "dark" ? "dark" : "light", + searchKeyword: "", + userNameMap: new Map() +}; + +function emitStateEvent(type) { + document.dispatchEvent(new CustomEvent(`teamup:${type}`)); +} + +export function isLoggedIn() { + return Boolean(state.token && Number.isFinite(state.uid)); +} + +export function setSession(token, uid) { + state.token = token || ""; + state.uid = Number.isFinite(uid) ? uid : null; + if (isLoggedIn()) { + localStorage.setItem(TOKEN_KEY, state.token); + localStorage.setItem(UID_KEY, String(state.uid)); + } else { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(UID_KEY); + } + emitStateEvent("session"); +} + +export function clearSession() { + setSession("", null); +} + +export function setTheme(theme) { + state.theme = theme === "dark" ? "dark" : "light"; + localStorage.setItem(THEME_KEY, state.theme); + emitStateEvent("theme"); +} + +export function toggleTheme() { + setTheme(state.theme === "dark" ? "light" : "dark"); +} + +export function setSearchKeyword(keyword) { + state.searchKeyword = String(keyword || "").trim(); +} + +export function setUserMap(map) { + state.userNameMap = map instanceof Map ? map : new Map(); +} + +export function getUserName(uid) { + const id = Number(uid); + return state.userNameMap.get(id) || `用户#${uid ?? "-"}`; +} + +export function parseMembers(raw) { + if (!raw || typeof raw !== "string") { + return []; + } + return raw + .split(";") + .map((item) => Number.parseInt(item.trim(), 10)) + .filter((item) => Number.isFinite(item)); +} diff --git a/src/main/resources/static/src/ui/skeleton.js b/src/main/resources/static/src/ui/skeleton.js new file mode 100644 index 0000000..da8df5c --- /dev/null +++ b/src/main/resources/static/src/ui/skeleton.js @@ -0,0 +1,36 @@ +export function teamListSkeleton(count = 4) { + const cards = Array.from({ length: count }) + .map(() => ` +
    +
    +
    +
    +
    +
    + `) + .join(""); + return `
    ${cards}
    `; +} + +export function detailSkeleton() { + return ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `; +} diff --git a/src/main/resources/static/src/ui/toast.js b/src/main/resources/static/src/ui/toast.js new file mode 100644 index 0000000..a772dfd --- /dev/null +++ b/src/main/resources/static/src/ui/toast.js @@ -0,0 +1,12 @@ +const container = document.getElementById("toastContainer"); + +export function showToast(message, type = "info") { + if (!container) { + return; + } + const toast = document.createElement("div"); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + window.setTimeout(() => toast.remove(), 2500); +} diff --git a/src/main/resources/static/src/utils.js b/src/main/resources/static/src/utils.js new file mode 100644 index 0000000..eee64ae --- /dev/null +++ b/src/main/resources/static/src/utils.js @@ -0,0 +1,39 @@ +export const APPLICATION_STATE = { + WAIT: 0, + ACCEPT: 1, + DECLINE: 2 +}; + +export function isSuccess(code) { + return typeof code === "number" && code % 10 === 1; +} + +export function formatTime(value) { + if (!value) { + return "未知时间"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleString("zh-CN"); +} + +export function stateText(stateValue) { + if (stateValue === APPLICATION_STATE.ACCEPT) { + return "已通过"; + } + if (stateValue === APPLICATION_STATE.DECLINE) { + return "已拒绝"; + } + return "待处理"; +} + +export function escapeHtml(input) { + return String(input ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css deleted file mode 100644 index 97fd94a..0000000 --- a/src/main/resources/static/styles.css +++ /dev/null @@ -1,376 +0,0 @@ -:root { - --bg: #f4f7ff; - --panel: rgba(255, 255, 255, 0.85); - --text: #1c2540; - --muted: #6d7692; - --primary: #3553ff; - --accent: #00a97f; - --danger: #d74848; - --border: #dce3ff; - --shadow: 0 12px 35px rgba(48, 74, 170, 0.15); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: "Inter", "PingFang SC", "Microsoft YaHei", sans-serif; - color: var(--text); - background: radial-gradient(circle at 20% 0%, #e8edff, #f6f9ff 45%, #f3f6ff 100%); - min-height: 100vh; -} - -.bg-decoration { - position: fixed; - inset: auto -160px -180px auto; - width: 420px; - height: 420px; - border-radius: 50%; - background: radial-gradient(circle, rgba(53, 83, 255, 0.24), rgba(53, 83, 255, 0)); - pointer-events: none; -} - -.topbar { - position: sticky; - top: 0; - z-index: 20; - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 24px; - backdrop-filter: blur(8px); - background: rgba(246, 249, 255, 0.8); - border-bottom: 1px solid rgba(220, 227, 255, 0.7); -} - -.brand { - display: flex; - align-items: center; - gap: 12px; -} - -.brand-mark { - width: 44px; - height: 44px; - border-radius: 14px; - background: linear-gradient(135deg, #3553ff, #667eff); - color: #fff; - font-weight: 700; - display: inline-flex; - align-items: center; - justify-content: center; - box-shadow: var(--shadow); -} - -.brand-text h1 { - margin: 0; - font-size: 1.2rem; -} - -.brand-text p { - margin: 2px 0 0; - color: var(--muted); - font-size: 0.9rem; -} - -.topbar-actions { - display: flex; - gap: 10px; -} - -.layout { - display: grid; - grid-template-columns: 350px 1fr; - gap: 18px; - padding: 20px; -} - -.panel { - display: flex; - flex-direction: column; - gap: 14px; -} - -.card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 16px; - box-shadow: var(--shadow); - padding: 16px; -} - -.card h2 { - margin: 0 0 12px; - font-size: 1.05rem; -} - -.status-card p { - margin: 0; - color: var(--muted); -} - -.form-grid { - display: grid; - gap: 10px; -} - -.form-grid.compact { - margin-top: 10px; -} - -label { - display: grid; - gap: 6px; - font-size: 0.92rem; - color: var(--muted); -} - -input, -textarea { - border: 1px solid #cdd6fb; - border-radius: 12px; - padding: 10px 12px; - background: #fff; - font: inherit; - color: var(--text); - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -input:focus, -textarea:focus { - outline: none; - border-color: #7f94ff; - box-shadow: 0 0 0 3px rgba(53, 83, 255, 0.15); -} - -.btn { - border: none; - border-radius: 12px; - padding: 10px 14px; - font: inherit; - font-weight: 600; - cursor: pointer; - transition: transform 0.15s ease, opacity 0.2s ease; -} - -.btn:hover { - transform: translateY(-1px); -} - -.btn:disabled { - cursor: not-allowed; - opacity: 0.55; - transform: none; -} - -.btn-primary { - background: var(--primary); - color: #fff; -} - -.btn-accent { - background: var(--accent); - color: #fff; -} - -.btn-danger { - background: var(--danger); - color: #fff; -} - -.btn-ghost { - background: #eaf0ff; - color: #2d49da; -} - -.search-form { - display: grid; - grid-template-columns: 1fr auto auto; - gap: 10px; -} - -.summary { - margin-top: 10px; - color: var(--muted); - font-size: 0.92rem; -} - -.team-list { - display: grid; - gap: 12px; -} - -.team-item { - background: rgba(255, 255, 255, 0.95); - border: 1px solid var(--border); - border-radius: 14px; - padding: 14px; - display: grid; - gap: 10px; -} - -.team-head { - display: flex; - justify-content: space-between; - gap: 10px; - align-items: baseline; -} - -.team-name { - margin: 0; - font-size: 1rem; -} - -.pill { - display: inline-block; - border-radius: 999px; - padding: 4px 10px; - background: #edf2ff; - color: #3653f9; - font-size: 0.78rem; -} - -.team-meta { - margin: 0; - color: var(--muted); - font-size: 0.9rem; -} - -.team-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.team-detail .detail-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.detail-meta { - color: var(--muted); - font-size: 0.92rem; - margin-top: 6px; -} - -.detail-content { - margin: 12px 0; - line-height: 1.6; -} - -.detail-columns { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -.sub-card { - border: 1px solid var(--border); - border-radius: 14px; - padding: 12px; - background: #fff; -} - -.sub-card h3 { - margin: 0 0 10px; - font-size: 0.98rem; -} - -.list { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: 8px; - max-height: 320px; - overflow: auto; -} - -.list-item { - border: 1px solid #e1e7ff; - border-radius: 10px; - padding: 9px 10px; - background: #fbfcff; -} - -.list-item p { - margin: 0; - line-height: 1.45; -} - -.list-item small { - display: block; - margin-top: 6px; - color: #7b84a1; -} - -.application-actions { - margin-top: 8px; - display: flex; - gap: 8px; -} - -.hidden { - display: none !important; -} - -.empty { - color: var(--muted); - text-align: center; - padding: 20px; -} - -.toast-container { - position: fixed; - right: 20px; - bottom: 20px; - display: grid; - gap: 8px; - z-index: 30; -} - -.toast { - min-width: 240px; - padding: 10px 12px; - border-radius: 12px; - color: #fff; - box-shadow: var(--shadow); - font-size: 0.92rem; -} - -.toast.info { - background: #3857ff; -} - -.toast.success { - background: #05a479; -} - -.toast.error { - background: #d94a4a; -} - -@media (max-width: 1080px) { - .layout { - grid-template-columns: 1fr; - } - - .detail-columns { - grid-template-columns: 1fr; - } -} - -@media (max-width: 640px) { - .topbar { - align-items: flex-start; - flex-direction: column; - gap: 10px; - } - - .search-form { - grid-template-columns: 1fr; - } -} diff --git a/src/main/resources/static/styles/base.css b/src/main/resources/static/styles/base.css new file mode 100644 index 0000000..13c4446 --- /dev/null +++ b/src/main/resources/static/styles/base.css @@ -0,0 +1,336 @@ +:root { + --radius-lg: 16px; + --radius-md: 12px; + --radius-sm: 10px; + --layout-gap: 16px; + --shadow: 0 12px 30px rgba(20, 31, 70, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Inter", "PingFang SC", "Microsoft YaHei", sans-serif; + transition: background 0.25s ease, color 0.25s ease; +} + +a { + color: inherit; + text-decoration: none; +} + +.bg-decoration { + position: fixed; + width: 420px; + height: 420px; + border-radius: 999px; + right: -140px; + bottom: -180px; + pointer-events: none; +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 16px; + align-items: center; + padding: 14px 22px; + border-bottom: 1px solid; + backdrop-filter: blur(10px); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 42px; + height: 42px; + border-radius: 14px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.brand-text h1 { + margin: 0; + font-size: 1.15rem; +} + +.brand-text p { + margin: 2px 0 0; + font-size: 0.88rem; +} + +.topbar-status p { + margin: 0; + font-size: 0.9rem; +} + +.topbar-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.page-shell { + padding: 18px; +} + +.layout-two-columns { + display: grid; + grid-template-columns: 350px 1fr; + gap: var(--layout-gap); +} + +.panel { + display: grid; + gap: 12px; + align-content: start; +} + +.card { + border: 1px solid; + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 14px; +} + +.card h2, +.card h3 { + margin: 0 0 10px; +} + +.hint { + margin: 6px 0 0; + font-size: 0.86rem; +} + +.muted { + opacity: 0.88; + font-size: 0.9rem; +} + +.badge { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; +} + +.form-grid { + display: grid; + gap: 10px; +} + +label { + display: grid; + gap: 6px; + font-size: 0.9rem; +} + +input, +textarea { + border: 1px solid; + border-radius: var(--radius-md); + padding: 10px 12px; + font: inherit; + resize: vertical; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +input:focus, +textarea:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(84, 108, 255, 0.22); +} + +.btn { + border: 1px solid transparent; + border-radius: var(--radius-md); + padding: 9px 13px; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, opacity 0.2s ease, background 0.2s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.search-form { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; +} + +.summary { + margin-top: 8px; + font-size: 0.9rem; +} + +.team-list { + display: grid; + gap: 12px; +} + +.team-item { + border: 1px solid; + border-radius: var(--radius-lg); + padding: 12px; + display: grid; + gap: 8px; +} + +.team-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.team-name { + margin: 0; + font-size: 1rem; +} + +.team-meta { + margin: 0; + font-size: 0.9rem; +} + +.team-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.empty { + text-align: center; + padding: 20px; + border-radius: var(--radius-lg); + border: 1px dashed; +} + +.detail-grid { + display: grid; + gap: 12px; +} + +.detail-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 8px; +} + +.list-item { + border: 1px solid; + border-radius: var(--radius-sm); + padding: 10px; +} + +.list-item p { + margin: 0; +} + +.list-item small { + display: block; + margin-top: 6px; + font-size: 0.82rem; +} + +.sub-actions { + margin-top: 8px; + display: flex; + gap: 8px; +} + +.hidden { + display: none !important; +} + +.toast-container { + position: fixed; + right: 18px; + bottom: 18px; + display: grid; + gap: 8px; + z-index: 50; +} + +.toast { + min-width: 220px; + padding: 10px 12px; + border-radius: var(--radius-md); + color: #fff; + box-shadow: var(--shadow); + font-size: 0.9rem; +} + +.toast.info { + background: #4c63ff; +} + +.toast.success { + background: #02a570; +} + +.toast.error { + background: #d34949; +} + +@media (max-width: 1120px) { + .layout-two-columns { + grid-template-columns: 1fr; + } + + .detail-columns { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .topbar { + grid-template-columns: 1fr; + } + + .topbar-actions { + justify-content: flex-start; + } + + .search-form { + grid-template-columns: 1fr; + } +} diff --git a/src/main/resources/static/styles/skeleton.css b/src/main/resources/static/styles/skeleton.css new file mode 100644 index 0000000..619788a --- /dev/null +++ b/src/main/resources/static/styles/skeleton.css @@ -0,0 +1,57 @@ +.skeleton { + position: relative; + overflow: hidden; + background: rgba(134, 148, 194, 0.14); +} + +.skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.34), transparent); + animation: shimmer 1.35s infinite; +} + +body[data-theme="dark"] .skeleton { + background: rgba(141, 160, 227, 0.18); +} + +body[data-theme="dark"] .skeleton::after { + background: linear-gradient(90deg, transparent, rgba(220, 231, 255, 0.18), transparent); +} + +.skeleton-card { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 12px; + display: grid; + gap: 9px; +} + +.skeleton-line { + height: 12px; + border-radius: 8px; +} + +.skeleton-line.w-30 { + width: 30%; +} + +.skeleton-line.w-50 { + width: 50%; +} + +.skeleton-line.w-70 { + width: 70%; +} + +.skeleton-line.w-100 { + width: 100%; +} + +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} diff --git a/src/main/resources/static/styles/theme.css b/src/main/resources/static/styles/theme.css new file mode 100644 index 0000000..5937d89 --- /dev/null +++ b/src/main/resources/static/styles/theme.css @@ -0,0 +1,135 @@ +body[data-theme="light"] { + --bg: radial-gradient(circle at 20% 0%, #e6edff, #f6f9ff 45%, #f3f5ff 100%); + --text: #1d2544; + --muted: #667197; + --panel: rgba(255, 255, 255, 0.88); + --panel-strong: #ffffff; + --border: #d9e2ff; + --line: #dce4ff; + --focus: #6f87ff; + --brand-bg: linear-gradient(135deg, #3553ff, #6d7eff); + --brand-text: #ffffff; + --btn-ghost-bg: #eaf0ff; + --btn-ghost-text: #3752df; + --btn-primary-bg: #3f59ff; + --btn-primary-text: #ffffff; + --btn-accent-bg: #0da87c; + --btn-accent-text: #ffffff; + --btn-danger-bg: #d84f4f; + --btn-danger-text: #ffffff; + --badge-bg: #edf1ff; + --badge-text: #3e56dc; + --bg-orb: radial-gradient(circle, rgba(66, 95, 255, 0.22), rgba(66, 95, 255, 0)); +} + +body[data-theme="dark"] { + --bg: radial-gradient(circle at 20% 0%, #12182f, #0f1426 44%, #0b1020 100%); + --text: #e7ecff; + --muted: #a8b4de; + --panel: rgba(20, 27, 49, 0.86); + --panel-strong: #1a2342; + --border: #2a396b; + --line: #304373; + --focus: #7f98ff; + --brand-bg: linear-gradient(135deg, #556eff, #8c9cff); + --brand-text: #ffffff; + --btn-ghost-bg: #24335f; + --btn-ghost-text: #dbe3ff; + --btn-primary-bg: #5d74ff; + --btn-primary-text: #ffffff; + --btn-accent-bg: #11a780; + --btn-accent-text: #ffffff; + --btn-danger-bg: #dc5a5a; + --btn-danger-text: #ffffff; + --badge-bg: #273665; + --badge-text: #cdd7ff; + --bg-orb: radial-gradient(circle, rgba(95, 116, 255, 0.28), rgba(95, 116, 255, 0)); +} + +body { + background: var(--bg); + color: var(--text); +} + +.bg-decoration { + background: var(--bg-orb); +} + +.topbar { + background: color-mix(in srgb, var(--panel) 92%, transparent); + border-bottom-color: var(--border); +} + +.brand-mark { + background: var(--brand-bg); + color: var(--brand-text); +} + +.brand-text p, +.topbar-status p, +.muted, +.hint, +.summary, +.team-meta, +.list-item small { + color: var(--muted); +} + +.card, +.team-item, +.list-item { + background: var(--panel); + border-color: var(--border); +} + +.empty { + border-color: var(--line); + color: var(--muted); +} + +label { + color: var(--muted); +} + +input, +textarea { + background: var(--panel-strong); + color: var(--text); + border-color: var(--line); +} + +input:focus, +textarea:focus { + border-color: var(--focus); +} + +.btn-primary { + background: var(--btn-primary-bg); + color: var(--btn-primary-text); +} + +.btn-accent { + background: var(--btn-accent-bg); + color: var(--btn-accent-text); +} + +.btn-danger { + background: var(--btn-danger-bg); + color: var(--btn-danger-text); +} + +.btn-ghost { + background: var(--btn-ghost-bg); + color: var(--btn-ghost-text); +} + +.btn-outline { + background: transparent; + color: var(--text); + border-color: var(--line); +} + +.badge { + background: var(--badge-bg); + color: var(--badge-text); +}