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 课程组队平台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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)}
+
+
+
+ `;
+}
+
+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 `
+
+ `;
+}
+
+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);
+}