19 Revize 38d1795a4c ... 4333bcf4e7

Autor SHA1 Zpráva Datum
  yangyi 4333bcf4e7 style: 新增/编辑题目对话框的时间限制、内存限制、难度、公开状态一行排列(label-position=top) před 2 týdny
  yangyi 839ab226cc style: 新增/编辑题目对话框改为 label-position=top,时间/内存输入框 controls-position=right před 2 týdny
  yangyi d75757ae33 style: 新增/编辑题目对话框标签文字统一左对齐 před 2 týdny
  yangyi a73c23791d fix: 题目描述编辑器占满对话框宽度 před 2 týdny
  yangyi 4501809245 refactor: 题目描述单栏显示(恢复全宽编辑器)并移除实时预览 před 2 týdny
  yangyi 1310671776 feat: 题目新增/编辑对话框描述左右分栏实时渲染,标题和按钮居中 před 2 týdny
  yangyi b1f70e6ab9 对齐后台管理头部用户信息展示与用户端一致,并实现登录/退出 před 2 týdny
  yangyi d1461d1a04 style: 用户端头部导航改为朴素文本链接,与后台头部统一 před 2 týdny
  yangyi 3fa63bb0ee style: 用户端头部添加与后台一致的分割线 před 2 týdny
  yangyi 092c5b56a3 fix: 后台侧栏折叠时宽度跟随菜单收缩 před 2 týdny
  yangyi e5063c53b5 docs: 添加 AGENTS.md 协作约定 před 2 týdny
  yangyi 1eecae7f33 fix: 统一接口错误提示并消除重复消息 před 2 týdny
  yangyi 0005725c9b fix:修正网页标题 před 2 týdny
  yangyi 8544c8a401 style: align contest detail and submission list layout with problem pages před 2 týdny
  yangyi efe636c176 style: adjust problem detail layout to full width, half split, beautify back button před 2 týdny
  yangyi 81becfb53b chore: hide creator on frontend problem detail page před 2 týdny
  yangyi 657183512d chore: update admin layout title to '分布式OJ系统' před 2 týdny
  yangyi 8274cf78bd chore: update site title to '分布式OJ系统' před 2 týdny
  yangyi 5efa2c6e6a chore: set problems page as home; remove home/about pages; clean up nav redirects; remove public status filter from user problem list před 2 týdny

+ 34 - 0
AGENTS.md

@@ -0,0 +1,34 @@
+# AGENTS.md
+
+## Commands
+
+- `npm run dev` — Vite dev server on port 5173. Dev proxy forwards `/api` to `http://localhost:8080` and strips the prefix.
+- `npm run build` — the only automated check: `vue-tsc -b && vite build`. There are no test or lint scripts.
+- `npm run api:gen` — regenerates the Orval client from `http://localhost:8080/v3/api-docs`; the backend must be running. `clean: true` deletes/recreates `src/api`, so never hand-edit `src/api/**`.
+- `VITE_API_BASE_URL` overrides the axios base URL (defaults to `/api`); create `.env.local` to call a non-proxied backend.
+
+## Architecture
+
+- Vue 3 + TypeScript + Vite + Element Plus (zh-CN) + Pinia with persisted state.
+- Entry: `src/main.ts`. Routes and auth guards: `src/router/index.ts`. `@` aliases to `src/`.
+- Generated API controllers live in `src/api/*-controller.ts`, models in `src/api/models/`. They call the mutator `customAxiosInstance` from `src/util/axios-instance.ts`.
+- Layouts: `src/layout/UserLayout.vue` (public) and `src/layout/AdminLayout.vue` (admin/coach). Route meta: `requiresAdmin` (admin only), `requiresCoach` (admin and coach). Roles: `admin`, `coach`, `user`, `guest`.
+
+## HTTP conventions
+
+- The response interceptor returns `response.data`, i.e. the `Response<T>` wrapper. Use results as `res.code`, `res.message`, `res.data`.
+- Success means `code === 0 || code === 200`.
+- Error messages are centralized in `src/util/axios-instance.ts`. Views keep local validation messages and their own `ElMessage.success` for operation feedback.
+- Do not add `ElMessage.error` for API failures in views, and do not add success toasts in the interceptor. Duplicating these layers caused every API call to show repeated messages.
+- The backend success `message` is always `"success"`; it is not user-facing text.
+- Token lives in `localStorage.token`; the request interceptor injects `Authorization: Bearer <token>`. HTTP 401 shows a message and redirects to `/login`.
+
+## Workflow
+
+- Commit to the local repository after completing each work point (one coherent change, fix, or feature) before starting the next.
+- Keep each commit scoped to that work point; do not batch unrelated changes.
+
+## Gotchas
+
+- `tsconfig.app.json` enables `strict`, `noUnusedLocals`, and `noUnusedParameters`; remove unused imports or `npm run build` fails.
+- `dist/` is build output and `src/api/**` is generated output; neither should be edited manually.

+ 4 - 3
README.md

@@ -79,10 +79,11 @@ VITE_API_BASE_URL=http://localhost:8080
 `src/util/axios-instance.ts` 集中处理鉴权与提示:
 
 - 请求拦截器从 `localStorage.token` 读取令牌并注入 `Authorization: Bearer <token>`。
-- 响应拦截器按业务状态码判断:`code` 为 `0` 或 `200` 视为成功;成功且带 `message` 时弹出 `ElMessage.success`,失败时弹出后端返回的 `message` 并 reject。
-- HTTP `401` 统一跳转登录页。
+- 响应拦截器按业务状态码判断:`code` 为 `0` 或 `200` 视为成功并直接返回 `response.data`;失败时统一弹出后端返回的 `message` 并 reject。
+- HTTP/网络异常同样由拦截器统一提示(相同文案 1.5 秒内去重);HTTP `401` 统一提示并跳转登录页。
+- 后端成功响应的 `message` 固定为 `success`,因此拦截器不再弹成功提示;操作成功文案与表单校验提示保留在视图层。
 
-视图层因此不需要各自处理错误提示,只需 `try/catch` 处理业务失败分支。
+视图层不再处理接口错误提示,只需 `try/catch` 保留必要的状态重置、页面跳转等控制流。
 
 ## 接口代码生成
 

+ 1 - 1
index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/logo.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>前端项目模板 Vite + Vue + TS</title>
+    <title>分布式OJ系统</title>
   </head>
   <body>
     <div id="app"></div>

+ 55 - 23
src/layout/AdminLayout.vue

@@ -3,15 +3,17 @@ import {useLoginUserStore} from "../store";
 import {computed, type ComputedRef, ref} from "vue";
 import type {RouteRecordRaw} from "vue-router";
 import router, {adminRoutes} from "../router";
+import {Expand, Fold, UserFilled} from "@element-plus/icons-vue";
 
-const title = ref<string>("标题");
+const title = ref<string>("分布式OJ系统");
 const loginUserStore = useLoginUserStore()
 const login = computed(()=>loginUserStore.loginUser.isLogin)
 const loginHandler = () => {
-
+  router.push('/login')
 }
 const logoutHandler = () => {
-
+  loginUserStore.logoutUser()
+  router.push('/problems')
 }
 const menus:ComputedRef<RouteRecordRaw[] | undefined> = computed(()=>{
   const role = loginUserStore.loginUser.user?.role
@@ -22,18 +24,12 @@ const menus:ComputedRef<RouteRecordRaw[] | undefined> = computed(()=>{
   });
 })
 const isCollapse = ref(false)
-const handleOpen = (key: string, keyPath: string[]) => {
-  console.log(key, keyPath)
-}
-const handleClose = (key: string, keyPath: string[]) => {
-  console.log(key, keyPath)
-}
 </script>
 
 <template>
   <el-container>
     <el-header class="header">
-      <div class="header-left" @click="router.push('/')">
+      <div class="header-left" @click="router.push('/problems')">
         <img src="/logo.svg" alt="logo" class="logo">
         <h2 class="title">{{title}}</h2>
       </div>
@@ -41,34 +37,40 @@ const handleClose = (key: string, keyPath: string[]) => {
       <div class="header-right">
         <template v-if="login">
           <el-dropdown placement="bottom">
-            <el-button> {{ loginUserStore.loginUser?.user?.name }} </el-button>
+            <el-button class="user-btn" text>
+              <el-icon class="user-avatar"><UserFilled /></el-icon>
+              <span class="username">{{ loginUserStore.loginUser?.user?.name }}</span>
+            </el-button>
             <template #dropdown>
               <el-dropdown-menu>
                 <el-dropdown-item>个人信息</el-dropdown-item>
-                <el-dropdown-item @click="logoutHandler">退出</el-dropdown-item>
+                <el-dropdown-item divided @click="logoutHandler">退出登录</el-dropdown-item>
               </el-dropdown-menu>
             </template>
           </el-dropdown>
         </template>
         <template v-else>
           <el-button type="primary" size="small" @click="loginHandler">登录</el-button>
-          <el-button size="small">注册</el-button>
+          <el-button size="small" @click="loginHandler">注册</el-button>
         </template>
       </div>
     </el-header>
 
     <el-container class="main-container">
-      <el-aside class="menu">
-        <el-radio-group v-model="isCollapse" style="margin-bottom: 20px;display: flex;justify-content: center">
-          <el-radio-button :value="false">expand</el-radio-button>
-          <el-radio-button :value="true">collapse</el-radio-button>
-        </el-radio-group>
+      <el-aside class="menu" :width="isCollapse ? '64px' : '200px'">
+        <el-button
+            class="collapse-btn"
+            text
+            :title="isCollapse ? '展开' : '收起'"
+            @click="isCollapse = !isCollapse"
+        >
+          <el-icon><component :is="isCollapse ? Expand : Fold"/></el-icon>
+          <span v-if="!isCollapse">收起</span>
+        </el-button>
         <el-menu
             router
             class="el-menu-vertical"
             :collapse="isCollapse"
-            @open="handleOpen"
-            @close="handleClose"
         >
           <el-menu-item :index="menu.path" v-for="menu in menus" :key="menu.path">
             <el-icon><Component :is="menu.meta?.icon"/></el-icon>
@@ -114,15 +116,45 @@ const handleClose = (key: string, keyPath: string[]) => {
   align-items: center;
   gap: 10px;
 }
+.username {
+  font-size: 14px;
+  color: #606266;
+}
+
+.user-btn {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  cursor: pointer;
+  transition: color 0.2s;
+}
+
+.user-btn:hover {
+  color: #db2777;
+}
+
+.user-avatar {
+  font-size: 20px;
+  color: #be185d;
+}
 
 .menu{
-  width: 200px;
+  transition: width 0.3s;
+  overflow: hidden;
+}
+.collapse-btn{
+  width: 100%;
+  margin-bottom: 10px;
+  justify-content: center;
 }
 .main-container {
   flex: 1;
 }
-.el-menu-vertical-demo:not(.el-menu--collapse) {
-  width: 200px;
+.el-menu-vertical{
+  border-right: none;
+}
+.el-menu-vertical:not(.el-menu--collapse) {
+  width: 100%;
   min-height: 400px;
 }
 </style>

+ 49 - 24
src/layout/UserLayout.vue

@@ -2,26 +2,31 @@
   <div class="user-layout">
     <el-container>
       <el-header class="header">
-        <div class="header-left" @click="router.push('/')">
+        <div class="header-left" @click="router.push('/problems')">
           <img :src="metaStore.logo" alt="logo" class="logo">
           <h2 class="title">{{metaStore.title}}</h2>
         </div>
-        <el-menu
-            :default-active="activeIndex"
-            mode="horizontal"
-            router
-            @select="handleSelect"
-            class="nav-menu"
-        >
-          <el-menu-item :index="menu.path" v-for="menu in menus" :key="menu.path">
+        <nav class="nav">
+          <router-link
+              v-for="menu in menus"
+              :key="menu.path"
+              :to="menu.path"
+              class="nav-link"
+              :class="{ active: currentPath.startsWith(menu.path) }"
+          >
             <el-icon><component :is="menu.meta?.icon"/></el-icon>
-            <template #title>{{ menu.meta?.title }}</template>
-          </el-menu-item>
-          <el-menu-item v-if="isStaff" index="/admin">
+            <span>{{ menu.meta?.title }}</span>
+          </router-link>
+          <router-link
+              v-if="isStaff"
+              to="/admin"
+              class="nav-link"
+              :class="{ active: currentPath.startsWith('/admin') }"
+          >
             <el-icon><Tools/></el-icon>
-            <template #title>后台管理</template>
-          </el-menu-item>
-        </el-menu>
+            <span>后台管理</span>
+          </router-link>
+        </nav>
         <div class="header-right">
           <template v-if="login">
             <el-dropdown placement="bottom">
@@ -54,13 +59,15 @@
 </template>
 
 <script setup lang="ts">
-import {computed, ref} from 'vue'
+import {computed} from 'vue'
+import {useRoute} from 'vue-router'
 import {useLoginUserStore, useMetaStore} from "../store";
 import router, {userRoutes} from "../router";
 import {
   Tools,
   UserFilled
 } from '@element-plus/icons-vue'
+const route = useRoute()
 const metaStore = useMetaStore()
 const loginUserStore = useLoginUserStore()
 const login = computed(() => loginUserStore.loginUser.isLogin)
@@ -71,16 +78,13 @@ const isStaff = computed(() => {
 const loginHandler = () => {
   router.push('/login')
 }
-let activeIndex = ref('/')
+const currentPath = computed(() => route.path)
 const menus = computed(()=>{
-  return userRoutes[0].children?.filter((child) => !String(child.path).includes(':'));
+  return userRoutes[0].children?.filter((child) => !String(child.path).includes(':') && child.path !== '/');
 })
-const handleSelect = () => {
-  //todo: 处理菜单选择
-}
 const logoutHandler = () => {
   loginUserStore.logoutUser()
-  router.push('/')
+  router.push('/problems')
 }
 </script>
 <style scoped>
@@ -97,6 +101,7 @@ el-main{
   align-items: center;
   justify-content: space-between;
   padding: 0 20px;
+  border-bottom: 1px solid;
 }
 .header-left {
   display: flex;
@@ -111,11 +116,31 @@ el-main{
   margin: 0;
   font-size: 20px;
 }
-.nav-menu {
+.nav {
   flex: 1;
   display: flex;
   justify-content: center;
-  border-bottom: none;
+  align-items: center;
+  gap: 28px;
+}
+.nav-link {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  height: auto;
+  padding: 2px 4px;
+  font-size: 14px;
+  color: #606266;
+  text-decoration: none;
+  border-bottom: 2px solid transparent;
+  transition: color 0.2s, border-color 0.2s;
+}
+.nav-link:hover {
+  color: #db2777;
+}
+.nav-link.active {
+  color: #db2777;
+  border-bottom-color: #db2777;
 }
 .header-right {
   display: flex;

+ 4 - 21
src/router/index.ts

@@ -1,7 +1,6 @@
 import {createRouter, createWebHistory, type Router, type RouteRecordRaw} from 'vue-router'
 
-import HomeView from '../view/HomeView.vue'
-import AboutView from '../view/AboutView.vue'
+
 import LoginView from '../view/LoginView.vue'
 import UserLayout from '../layout/UserLayout.vue'
 import AdminLayout from "../layout/AdminLayout.vue";
@@ -16,8 +15,6 @@ import ContestDetailView from "../view/contest/ContestDetailView.vue";
 import ContestRankingView from "../view/contest/ContestRankingView.vue";
 import SubmissionListView from "../view/submission/SubmissionListView.vue";
 import {
-    HomeFilled,
-    Menu,
     User,
     Document,
     Files,
@@ -36,21 +33,7 @@ export const userRoutes:RouteRecordRaw[] = [
         children: [
             {
                 path: '/',
-                name: 'home',
-                component: HomeView,
-                meta: {
-                    title: '首页',
-                    icon: HomeFilled
-                }
-            },
-            {
-                path: '/about',
-                name: 'about',
-                component: AboutView,
-                meta: {
-                    title: '关于',
-                    icon: Menu
-                }
+                redirect: '/problems'
             },
             {
                 path: '/problems',
@@ -184,7 +167,7 @@ router.beforeEach((to, _from, next) => {
             next({path: '/login', query: {redirect: to.fullPath}})
         } else if (role !== 'admin') {
             ElMessage.error('无权访问')
-            next('/')
+            next('/problems')
         } else {
             next()
         }
@@ -194,7 +177,7 @@ router.beforeEach((to, _from, next) => {
             next({path: '/login', query: {redirect: to.fullPath}})
         } else if (role !== 'admin' && role !== 'coach') {
             ElMessage.error('无权访问')
-            next('/')
+            next('/problems')
         } else {
             next()
         }

+ 1 - 1
src/store/meta.ts

@@ -3,7 +3,7 @@ import {ref} from "vue";
 // 网站配置元数据
 export const useMetaStore = defineStore("meta",()=>{
     //网站标题
-    const title = ref('标题');
+    const title = ref('分布式OJ系统');
     //网站logo
     const logo = ref('/logo.svg');
     //后端地址

+ 43 - 11
src/util/axios-instance.ts

@@ -31,26 +31,58 @@ axiosInstance.interceptors.request.use(
     }
 );
 
+const ERROR_MESSAGE_INTERVAL = 1500;
+let lastErrorMessage = '';
+let lastErrorTime = 0;
+
+const showError = (message: string) => {
+    const now = Date.now();
+    if (message === lastErrorMessage && now - lastErrorTime < ERROR_MESSAGE_INTERVAL) {
+        return;
+    }
+    lastErrorMessage = message;
+    lastErrorTime = now;
+    ElMessage.error(message);
+};
+
+const getErrorMessage = (error: any): string => {
+    if (error.response?.data?.message) {
+        return error.response.data.message;
+    }
+    if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
+        return '请求超时,请稍后重试';
+    }
+    if (!error.response) {
+        return '网络异常,请检查网络连接';
+    }
+    return `请求失败(${error.response.status})`;
+};
+
 // 响应拦截器
 axiosInstance.interceptors.response.use(
     (response: AxiosResponse) => {
         // 根据后端返回格式调整
-        if (response.data.code !== 0 && response.data.code !== 200) {
-            if (response.data.message) {
-                ElMessage.error(response.data.message);
-            }
-            return Promise.reject(new Error(response.data.message || '请求失败'));
-        }
-        if (response.data.message) {
-            ElMessage.success(response.data.message);
+        const data = response.data;
+        if (data?.code !== 0 && data?.code !== 200) {
+            const message = data?.message || '请求失败';
+            showError(message);
+            const error = new Error(message) as Error & { code?: number; data?: unknown };
+            error.code = data?.code;
+            error.data = data?.data;
+            return Promise.reject(error);
         }
-        return response.data;
+        return data;
     },
     (error) => {
         // 统一错误处理
-        if (error.response?.status === 401) {
+        const status = error.response?.status;
+        const message = status === 401 ? '登录已过期,请重新登录' : getErrorMessage(error);
+        showError(message);
+        if (status === 401) {
             // 未授权,跳转登录
-            window.location.href = '/login';
+            window.setTimeout(() => {
+                window.location.href = '/login';
+            }, 500);
         }
         return Promise.reject(error);
     }

+ 0 - 14
src/view/AboutView.vue

@@ -1,14 +0,0 @@
-<template>
-<h1>这是about页面</h1>
-  <el-text>
-    这是基于Vite + Vue + TypeScript构建的前端模板
-  </el-text>
-</template>
-
-<script setup lang="ts">
-console.log('about')
-</script>
-
-<style scoped>
-
-</style>

+ 0 - 12
src/view/HomeView.vue

@@ -1,12 +0,0 @@
-<template>
-<el-text>
-  这是基于Vite + Vue + TypeScript构建的前端模板
-</el-text>
-</template>
-
-<script setup lang="ts">
-</script>
-
-<style scoped>
-
-</style>

+ 3 - 9
src/view/LoginView.vue

@@ -8,7 +8,7 @@
       </div>
 
       <el-card class="auth-card" shadow="never">
-        <el-button class="back-btn" text @click="router.push('/')">
+        <el-button class="back-btn" text @click="router.push('/problems')">
           <el-icon><ArrowLeft /></el-icon>
           返回主页
         </el-button>
@@ -213,11 +213,8 @@ const handleLogin = async () => {
       })
       ElMessage.success('登录成功')
       router.push((route.query.redirect as string) || '/')
-    } else {
-      ElMessage.error(res.message || '登录失败')
     }
-  } catch (err: any) {
-    ElMessage.error(err.message || '登录失败,请检查网络')
+  } catch {
   } finally {
     loginLoading.value = false
   }
@@ -243,11 +240,8 @@ const handleRegister = async () => {
       registerForm.username = ''
       registerForm.password = ''
       registerForm.confirmPassword = ''
-    } else {
-      ElMessage.error(res.message || '注册失败')
     }
-  } catch (err: any) {
-    ElMessage.error(err.message || '注册失败,请检查网络')
+  } catch {
   } finally {
     registerLoading.value = false
   }

+ 0 - 9
src/view/admin/CategoryManageView.vue

@@ -32,11 +32,8 @@ const fetchData = async () => {
     const res = await api.list()
     if (res?.code === 200) {
       tableData.value = res.data || []
-    } else {
-      ElMessage.error(res?.message || '获取分类失败')
     }
   } catch {
-    ElMessage.error('获取分类失败')
     tableData.value = []
   } finally {
     loading.value = false
@@ -73,11 +70,8 @@ const handleSave = async () => {
       ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
       dialogVisible.value = false
       fetchData()
-    } else {
-      ElMessage.error(res?.message || '保存失败')
     }
   } catch {
-    ElMessage.error('保存失败')
   }
 }
 
@@ -90,11 +84,8 @@ const handleDelete = (row: CategoryVo) => {
         if (res?.code === 200) {
           ElMessage.success('删除成功')
           fetchData()
-        } else {
-          ElMessage.error(res?.message || '删除失败')
         }
       } catch {
-        ElMessage.error('删除失败')
       }
     })
     .catch(() => {})

+ 4 - 29
src/view/admin/ContestManageView.vue

@@ -76,7 +76,6 @@ const fetchData = async () => {
     tableData.value = pageData?.data || []
     pagination.total = pageData?.total || 0
   } catch {
-    ElMessage.error('获取比赛列表失败')
     tableData.value = []
   } finally {
     loading.value = false
@@ -186,11 +185,8 @@ const handleSave = async () => {
       ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
       dialogVisible.value = false
       fetchData()
-    } else {
-      ElMessage.error(res?.message || '保存失败')
     }
   } catch {
-    ElMessage.error('保存失败')
   }
 }
 
@@ -203,11 +199,8 @@ const handleDelete = (row: ContestVo) => {
         if (res?.code === 200) {
           ElMessage.success('删除成功')
           fetchData()
-        } else {
-          ElMessage.error(res?.message || '删除失败')
         }
       } catch {
-        ElMessage.error('删除失败')
       }
     })
     .catch(() => {})
@@ -281,11 +274,8 @@ const loadConfig = async () => {
       existingProblems.value = (d.problems || []).map((p) => ({ ...p }))
       configProblems.value = (d.problems || []).map((p) => ({ ...p }))
       configLoaded.value = true
-    } else {
-      ElMessage.error(res?.message || '获取比赛详情失败')
     }
   } catch {
-    ElMessage.error('获取比赛详情失败')
   }
 }
 
@@ -332,34 +322,21 @@ const saveConfig = async () => {
       const old = existingMap.get(cp.problemId)
       const dto: ContestProblemDto = { problemId: cp.problemId!, score: cp.score ?? 0, displayOrder: cp.displayOrder ?? 1 }
       if (!old) {
-        const res = await api.addProblem(id, dto)
-        if (res?.code !== 200) {
-          ElMessage.error(res?.message || `添加题目 ${cp.title} 失败`)
-          return
-        }
+        await api.addProblem(id, dto)
       } else if (old.score !== cp.score || old.displayOrder !== cp.displayOrder) {
-        const res = await api.updateProblem(id, dto)
-        if (res?.code !== 200) {
-          ElMessage.error(res?.message || `更新题目 ${cp.title} 失败`)
-          return
-        }
+        await api.updateProblem(id, dto)
       }
     }
 
     for (const ex of existing) {
       if (!currentMap.has(ex.problemId)) {
-        const res = await api.removeProblem(id, { problemId: ex.problemId! })
-        if (res?.code !== 200) {
-          ElMessage.error(res?.message || `移除题目 ${ex.title} 失败`)
-          return
-        }
+        await api.removeProblem(id, { problemId: ex.problemId! })
       }
     }
 
     ElMessage.success('题目配置保存成功')
     configDialogVisible.value = false
   } catch {
-    ElMessage.error('保存题目配置失败')
   } finally {
     configSaving.value = false
   }
@@ -395,11 +372,9 @@ const openRegistrations = (row: ContestVo) => {
     .then((res) => {
       if (res?.code === 200) {
         regTable.value = res.data || []
-      } else {
-        ElMessage.error(res?.message || '获取报名名单失败')
       }
     })
-    .catch(() => ElMessage.error('获取报名名单失败'))
+    .catch(() => {})
     .finally(() => {
       regLoading.value = false
     })

+ 50 - 45
src/view/admin/ProblemManageView.vue

@@ -127,7 +127,6 @@ const fetchData = async () => {
     tableData.value = pageData?.data || []
     pagination.total = pageData?.total || 0
   } catch {
-    ElMessage.error('获取题目列表失败')
     tableData.value = []
   } finally {
     loading.value = false
@@ -191,7 +190,7 @@ const handleEdit = (row: ProblemVo) => {
       problemForm.categoryIds = d.categories?.map((c) => c.id).filter((c): c is number => c !== undefined) || []
       dialogVisible.value = true
     })
-    .catch(() => ElMessage.error('获取题目信息失败'))
+    .catch(() => {})
 }
 
 const handleSave = async () => {
@@ -213,11 +212,8 @@ const handleSave = async () => {
       ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
       dialogVisible.value = false
       fetchData()
-    } else {
-      ElMessage.error(res?.message || '保存失败')
     }
   } catch {
-    ElMessage.error('保存失败')
   }
 }
 
@@ -230,11 +226,8 @@ const handleDelete = (row: ProblemVo) => {
         if (res?.code === 200) {
           ElMessage.success('删除成功')
           fetchData()
-        } else {
-          ElMessage.error(res?.message || '删除失败')
         }
       } catch {
-        ElMessage.error('删除失败')
       }
     })
     .catch(() => {})
@@ -254,11 +247,8 @@ const handleBatchDelete = () => {
           ElMessage.success('删除成功')
           selectedRows.value = []
           fetchData()
-        } else {
-          ElMessage.error(res?.message || '删除失败')
         }
       } catch {
-        ElMessage.error('删除失败')
       }
     })
     .catch(() => {})
@@ -318,11 +308,8 @@ const saveCase = async () => {
       ElMessage.success('保存成功')
       caseDialogVisible.value = false
       loadDetail(currentProblemId.value!)
-    } else {
-      ElMessage.error(res?.message || '保存失败')
     }
   } catch {
-    ElMessage.error('保存失败')
   }
 }
 
@@ -335,11 +322,8 @@ const deleteCase = (row: ProblemCaseVo) => {
         if (res?.code === 200) {
           ElMessage.success('删除成功')
           loadDetail(currentProblemId.value!)
-        } else {
-          ElMessage.error(res?.message || '删除失败')
         }
       } catch {
-        ElMessage.error('删除失败')
       }
     })
     .catch(() => {})
@@ -383,11 +367,8 @@ const saveTest = async () => {
       ElMessage.success('保存成功')
       testDialogVisible.value = false
       loadDetail(currentProblemId.value!)
-    } else {
-      ElMessage.error(res?.message || '保存失败')
     }
   } catch {
-    ElMessage.error('保存失败')
   }
 }
 
@@ -400,11 +381,8 @@ const deleteTest = (row: ProblemTestVo) => {
         if (res?.code === 200) {
           ElMessage.success('删除成功')
           loadDetail(currentProblemId.value!)
-        } else {
-          ElMessage.error(res?.message || '删除失败')
         }
       } catch {
-        ElMessage.error('删除失败')
       }
     })
     .catch(() => {})
@@ -425,13 +403,9 @@ const toggleTestHidden = async (row: ProblemTestVo, val: number) => {
     const res = await api.updateTest(data)
     if (res?.code === 200) {
       ElMessage.success('更新成功')
-    } else {
-      row.isHidden = old
-      ElMessage.error(res?.message || '更新失败')
     }
   } catch {
     row.isHidden = old
-    ElMessage.error('更新失败')
   }
 }
 
@@ -541,8 +515,11 @@ onMounted(() => {
     </el-card>
 
     <!-- 新增/编辑题目 -->
-    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="900px" draggable>
-      <el-form ref="formRef" :model="problemForm" :rules="formRules" label-width="90px">
+    <el-dialog v-model="dialogVisible" width="960px" draggable>
+      <template #header>
+        <div class="dialog-header">{{ dialogTitle }}</div>
+      </template>
+      <el-form ref="formRef" :model="problemForm" :rules="formRules" label-position="top">
         <el-form-item label="标题" prop="title">
           <el-input v-model="problemForm.title" placeholder="请输入题目标题" />
         </el-form-item>
@@ -551,20 +528,30 @@ onMounted(() => {
             <MdEditor :value="problemForm.description || ''" :plugins="mdPlugins" @change="(v: string) => (problemForm.description = v)" />
           </div>
         </el-form-item>
-        <el-form-item label="时间限制" prop="timeLimitMs">
-          <el-input-number v-model="problemForm.timeLimitMs" :min="1" /> ms
-        </el-form-item>
-        <el-form-item label="内存限制" prop="memoryLimitMb">
-          <el-input-number v-model="problemForm.memoryLimitMb" :min="1" /> MB
-        </el-form-item>
-        <el-form-item label="难度" prop="difficulty">
-          <el-select v-model="problemForm.difficulty" style="width: 200px">
-            <el-option v-for="item in difficultyOptions" :key="item.value" :label="item.label" :value="item.value" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="公开" prop="isPublic">
-          <el-switch v-model="problemForm.isPublic" :active-value="1" :inactive-value="0" active-text="公开" inactive-text="未公开" />
-        </el-form-item>
+        <el-row :gutter="16">
+          <el-col :span="6">
+            <el-form-item label="时间限制" prop="timeLimitMs">
+              <el-input-number v-model="problemForm.timeLimitMs" :min="1" controls-position="right" style="width: 100%" /> ms
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="内存限制" prop="memoryLimitMb">
+              <el-input-number v-model="problemForm.memoryLimitMb" :min="1" controls-position="right" style="width: 100%" /> MB
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="难度" prop="difficulty">
+              <el-select v-model="problemForm.difficulty" style="width: 100%">
+                <el-option v-for="item in difficultyOptions" :key="item.value" :label="item.label" :value="item.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="公开" prop="isPublic">
+              <el-switch v-model="problemForm.isPublic" :active-value="1" :inactive-value="0" active-text="公开" inactive-text="未公开" />
+            </el-form-item>
+          </el-col>
+        </el-row>
         <el-form-item label="分类">
           <el-checkbox-group v-model="problemForm.categoryIds">
             <el-checkbox v-for="item in categoryOptions" :key="item.id" :value="item.id" :label="item.id">
@@ -574,8 +561,10 @@ onMounted(() => {
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="handleSave">确定</el-button>
+        <div class="dialog-footer">
+          <el-button @click="dialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="handleSave">确定</el-button>
+        </div>
       </template>
     </el-dialog>
 
@@ -708,8 +697,24 @@ onMounted(() => {
   margin-top: 16px;
 }
 
+.dialog-header {
+  text-align: center;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+}
+
+.markdown-editor-wrapper {
+  width: 100%;
+  flex: 1;
+}
+
 .markdown-editor-wrapper :deep(.bytemd) {
   height: 300px;
+  width: 100%;
 }
 
 .markdown-editor-wrapper.case-description :deep(.bytemd) {

+ 1 - 18
src/view/admin/UserManageView.vue

@@ -96,8 +96,7 @@ const fetchData = async () => {
     const pageData = res?.data
     tableData.value = pageData?.data || []
     pagination.total = pageData?.total || 0
-  } catch (err) {
-    ElMessage.error('获取数据失败')
+  } catch {
     tableData.value = []
   } finally {
     loading.value = false
@@ -141,7 +140,6 @@ const handleEdit = async (row: UserVo) => {
       dialogVisible.value = true
     }
   } catch {
-    ElMessage.error('获取用户信息失败')
   }
 }
 
@@ -167,8 +165,6 @@ const handleSave = async () => {
           ElMessage.success('修改成功')
           dialogVisible.value = false
           fetchData()
-        } else {
-          ElMessage.error(res?.message || '修改失败')
         }
       } else {
         if (!userForm.password) {
@@ -184,12 +180,9 @@ const handleSave = async () => {
           ElMessage.success('新增成功')
           dialogVisible.value = false
           fetchData()
-        } else {
-          ElMessage.error(res?.message || '新增失败')
         }
       }
     } catch {
-      ElMessage.error(isEdit.value ? '修改失败' : '新增失败')
     }
   })
 }
@@ -210,11 +203,8 @@ const handleDelete = (row: UserVo) => {
       if (res?.code === 0 || res?.code === 200) {
         ElMessage.success('删除成功')
         fetchData()
-      } else {
-        ElMessage.error(res?.message || '删除失败')
       }
     } catch {
-      ElMessage.error('删除失败')
     }
   }).catch(() => {})
 }
@@ -235,11 +225,8 @@ const handleBatchDelete = () => {
         ElMessage.success('删除成功')
         selectedRows.value = []
         fetchData()
-      } else {
-        ElMessage.error(res?.message || '删除失败')
       }
     } catch {
-      ElMessage.error('删除失败')
     }
   }).catch(() => {})
 }
@@ -253,13 +240,9 @@ const handleToggleEnable = async (row: UserVo, newEnable: number) => {
     const res = await api.updateUserStatus({ id: row.id!, enable: newEnable as UpdateUserStatusDto['enable'] })
     if (res?.code === 0 || res?.code === 200) {
       ElMessage.success('状态更新成功')
-    } else {
-      row.enable = oldEnable
-      ElMessage.error(res?.message || '状态更新失败')
     }
   } catch {
     row.enable = oldEnable
-    ElMessage.error('状态更新失败')
   }
 }
 

+ 5 - 7
src/view/contest/ContestDetailView.vue

@@ -97,11 +97,10 @@ const fetchDetail = async (password?: string) => {
         unlocked.value = true
       }
     } else {
-      ElMessage.error(res?.message || '获取比赛失败')
       router.push('/contests')
     }
   } catch {
-    ElMessage.error('获取比赛失败')
+    router.push('/contests')
   } finally {
     loading.value = false
   }
@@ -135,11 +134,8 @@ const doRegister = async (password?: string) => {
       ElMessage.success('报名成功')
       if (isPasswordType.value && password) validPassword.value = password
       await fetchDetail(validPassword.value)
-    } else {
-      ElMessage.error(res?.message || '报名失败')
     }
   } catch {
-    ElMessage.error('报名失败')
   }
 }
 
@@ -170,7 +166,7 @@ onMounted(fetchDetail)
 <template>
   <div class="contest-detail-view" v-loading="loading">
     <div class="detail-header">
-      <el-button text @click="router.push('/contests')">
+      <el-button type="primary" plain round @click="router.push('/contests')">
         <el-icon><ArrowLeft /></el-icon>返回列表
       </el-button>
       <div class="title-row">
@@ -251,11 +247,13 @@ onMounted(fetchDetail)
 <style scoped>
 .contest-detail-view {
   padding: 20px;
-  max-width: 1100px;
   margin: 0 auto;
 }
 
 .detail-header {
+  display: flex;
+  align-items: center;
+  gap: 16px;
   margin-bottom: 16px;
 }
 

+ 0 - 2
src/view/contest/ContestListView.vue

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import { getContestController } from '@/api/contest-controller'
 import type { ContestVo, QueryByPage2Params } from '@/api/models'
-import { ElMessage } from 'element-plus'
 import { RefreshRight, Search } from '@element-plus/icons-vue'
 import { onMounted, reactive, ref } from 'vue'
 import { useRouter } from 'vue-router'
@@ -67,7 +66,6 @@ const fetchData = async () => {
     tableData.value = pageData?.data || []
     pagination.total = pageData?.total || 0
   } catch {
-    ElMessage.error('获取比赛列表失败')
     tableData.value = []
   } finally {
     loading.value = false

+ 0 - 3
src/view/contest/ContestRankingView.vue

@@ -24,11 +24,8 @@ const fetchRanking = async () => {
     const res = await api.getRanking(contestId)
     if (res?.code === 200) {
       tableData.value = res.data || []
-    } else {
-      ElMessage.error(res?.message || '获取排行榜失败')
     }
   } catch {
-    ElMessage.error('获取排行榜失败')
   } finally {
     loading.value = false
   }

+ 7 - 13
src/view/problem/ProblemDetailView.vue

@@ -45,11 +45,8 @@ const fetchDetail = async () => {
     const res = await api.getDetail1(problemId.value)
     if (res?.code === 200 && res.data) {
       detail.value = res.data
-    } else {
-      ElMessage.error(res?.message || '获取题目失败')
     }
   } catch {
-    ElMessage.error('获取题目失败')
   } finally {
     loading.value = false
   }
@@ -109,11 +106,8 @@ const handleSubmit = async () => {
     if (res?.code === 200 && res.data) {
       result.value = res.data
       ElMessage.success('提交成功')
-    } else {
-      ElMessage.error(res?.message || '提交失败')
     }
   } catch {
-    ElMessage.error('提交失败')
   } finally {
     submitting.value = false
   }
@@ -130,7 +124,7 @@ onMounted(() => {
 <template>
   <div class="problem-detail-view">
     <div class="detail-header">
-      <el-button text @click="goBack">
+      <el-button type="primary" plain round @click="goBack">
         <el-icon><ArrowLeft /></el-icon>返回{{ contestId ? '比赛' : '列表' }}
       </el-button>
       <div class="title-row">
@@ -148,8 +142,7 @@ onMounted(() => {
             <el-descriptions-item label="内存限制">{{ detail.memoryLimitMb ?? '-' }} MB</el-descriptions-item>
             <el-descriptions-item label="提交次数">{{ detail.tryCount ?? 0 }}</el-descriptions-item>
             <el-descriptions-item label="通过次数">{{ detail.acceptedCount ?? 0 }}</el-descriptions-item>
-            <el-descriptions-item label="创建者">{{ detail.createdByUsername || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="分类" :span="3">
+            <el-descriptions-item label="分类" :span="4">
               <template v-if="detail.categories?.length">
                 <el-tag v-for="c in detail.categories" :key="c.id" class="category-tag" size="small">{{
                   c.categoryName
@@ -239,11 +232,13 @@ onMounted(() => {
 <style scoped>
 .problem-detail-view {
   padding: 20px;
-  max-width: 1400px;
   margin: 0 auto;
 }
 
 .detail-header {
+  display: flex;
+  align-items: center;
+  gap: 16px;
   margin-bottom: 16px;
 }
 
@@ -270,8 +265,8 @@ onMounted(() => {
 }
 
 .right-panel {
-  width: 44%;
-  min-width: 420px;
+  flex: 1;
+  min-width: 0;
 }
 
 .info-card,
@@ -375,7 +370,6 @@ onMounted(() => {
   }
   .right-panel {
     width: 100%;
-    min-width: 0;
   }
 }
 </style>

+ 3 - 17
src/view/problem/ProblemListView.vue

@@ -2,7 +2,6 @@
 import { getProblemController } from '@/api/problem-controller'
 import { getCategoryController } from '@/api/category-controller'
 import type { CategoryVo, ProblemVo, QueryByPage1Params } from '@/api/models'
-import { ElMessage } from 'element-plus'
 import { RefreshRight, Search } from '@element-plus/icons-vue'
 import { onMounted, reactive, ref } from 'vue'
 import { useRouter } from 'vue-router'
@@ -13,8 +12,7 @@ const api = getProblemController()
 const searchForm = reactive({
   title: '',
   difficulty: '',
-  categoryId: undefined as number | undefined,
-  isPublic: undefined as number | undefined
+  categoryId: undefined as number | undefined
 })
 
 const categoryOptions = ref<CategoryVo[]>([])
@@ -59,7 +57,6 @@ const fetchData = async () => {
       title: searchForm.title || undefined,
       difficulty: searchForm.difficulty || undefined,
       categoryId: searchForm.categoryId,
-      isPublic: searchForm.isPublic,
       pageNum: pagination.pageNum,
       pageSize: pagination.pageSize
     }
@@ -68,7 +65,6 @@ const fetchData = async () => {
     tableData.value = pageData?.data || []
     pagination.total = pageData?.total || 0
   } catch {
-    ElMessage.error('获取题目列表失败')
     tableData.value = []
   } finally {
     loading.value = false
@@ -84,7 +80,6 @@ const handleReset = () => {
   searchForm.title = ''
   searchForm.difficulty = ''
   searchForm.categoryId = undefined
-  searchForm.isPublic = undefined
   handleSearch()
 }
 
@@ -128,12 +123,7 @@ onMounted(() => {
             <el-option v-for="item in categoryOptions" :key="item.id" :label="item.categoryName" :value="item.id" />
           </el-select>
         </el-form-item>
-        <el-form-item label="公开状态">
-          <el-select v-model="searchForm.isPublic" placeholder="全部" clearable style="width: 120px">
-            <el-option label="公开" :value="1" />
-            <el-option label="未公开" :value="0" />
-          </el-select>
-        </el-form-item>
+
         <el-form-item>
           <el-button type="primary" @click="handleSearch">
             <el-icon class="el-icon--left"><Search /></el-icon>搜索
@@ -158,11 +148,7 @@ onMounted(() => {
             <el-tag :type="difficultyTag(row.difficulty)">{{ difficultyLabel(row.difficulty) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="公开状态" width="100" align="center">
-          <template #default="{ row }">
-            <el-tag :type="row.isPublic === 1 ? 'success' : 'info'">{{ row.isPublic === 1 ? '公开' : '未公开' }}</el-tag>
-          </template>
-        </el-table-column>
+
         <el-table-column label="时间限制" width="110" align="center">
           <template #default="{ row }">{{ row.timeLimitMs ?? '-' }} ms</template>
         </el-table-column>

+ 0 - 2
src/view/submission/SubmissionListView.vue

@@ -25,7 +25,6 @@ const fetchData = async () => {
     tableData.value = pageData?.data || []
     pagination.total = pageData?.total || 0
   } catch {
-    ElMessage.error('获取提交记录失败')
     tableData.value = []
   } finally {
     loading.value = false
@@ -124,7 +123,6 @@ onMounted(() => {
 <style scoped>
 .submission-list-view {
   padding: 20px;
-  max-width: 1200px;
   margin: 0 auto;
 }