diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..fe64698 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,39 @@ + + + + + + TeamUp 课程组队平台 + + + + + + + + +
+
+
+ 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/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); +}