Просмотр исходного кода

fix: 修复UI和系统功能Bug (#6078,#6079,#6080,#6083)

ethanfly 3 дней назад
Родитель
Сommit
76cdab11c9

+ 116 - 0
client/src/App.vue

@@ -1,11 +1,39 @@
 <template>
   <el-config-provider :locale="zhCn">
+    <!-- 全局加载界面(Splash Screen) -->
+    <transition name="splash-fade">
+      <div v-if="showSplash" class="splash-screen">
+        <div class="splash-content">
+          <div class="splash-logo">智媒通</div>
+          <div class="splash-dots">
+            <span></span>
+            <span></span>
+            <span></span>
+          </div>
+          <p class="splash-text">正在加载...</p>
+        </div>
+      </div>
+    </transition>
     <router-view />
   </el-config-provider>
 </template>
 
 <script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { useRouter } from 'vue-router';
 import zhCn from 'element-plus/es/locale/lang/zh-cn';
+
+const showSplash = ref(true);
+const router = useRouter();
+
+onMounted(() => {
+  // 路由首次导航完成后隐藏加载界面
+  router.isReady().then(() => {
+    setTimeout(() => {
+      showSplash.value = false;
+    }, 300);
+  });
+});
 </script>
 
 <style>
@@ -15,3 +43,91 @@ html, body, #app {
   padding: 0;
 }
 </style>
+
+<style scoped>
+.splash-screen {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 99999;
+}
+
+.splash-content {
+  text-align: center;
+  color: #fff;
+}
+
+.splash-logo {
+  font-size: 42px;
+  font-weight: 700;
+  letter-spacing: 4px;
+  margin-bottom: 40px;
+  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.splash-dots {
+  display: flex;
+  justify-content: center;
+  gap: 8px;
+  margin-bottom: 24px;
+}
+
+.splash-dots span {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.6);
+  animation: dot-bounce 1.4s ease-in-out infinite;
+}
+
+.splash-dots span:nth-child(1) {
+  animation-delay: 0s;
+}
+
+.splash-dots span:nth-child(2) {
+  animation-delay: 0.2s;
+}
+
+.splash-dots span:nth-child(3) {
+  animation-delay: 0.4s;
+}
+
+@keyframes dot-bounce {
+  0%, 80%, 100% {
+    transform: scale(0.6);
+    opacity: 0.4;
+    background: rgba(255, 255, 255, 0.4);
+  }
+  40% {
+    transform: scale(1);
+    opacity: 1;
+    background: #fff;
+  }
+}
+
+.splash-text {
+  font-size: 16px;
+  opacity: 0.8;
+  margin: 0;
+  animation: text-pulse 2s ease-in-out infinite;
+}
+
+@keyframes text-pulse {
+  0%, 100% { opacity: 0.5; }
+  50% { opacity: 1; }
+}
+
+.splash-fade-leave-active {
+  transition: opacity 0.4s ease;
+}
+
+.splash-fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 28 - 0
client/src/api/request.ts

@@ -78,6 +78,10 @@ function addRefreshSubscriber(callback: (token: string) => void) {
   refreshSubscribers.push(callback);
 }
 
+// GET 请求重试机制:对网络错误和超时自动重试
+const GET_RETRY_COUNT = 2;
+const GET_RETRY_DELAY = 1000; // 1秒
+
 // 响应拦截器
 request.interceptors.response.use(
   (response: AxiosResponse<ApiResponse>) => {
@@ -94,6 +98,18 @@ request.interceptors.response.use(
   async (error) => {
     const originalRequest = error.config;
 
+    // GET 请求自动重试:网络错误或超时时重试,避免后端暂时不可用导致失败
+    const isGetRequest = originalRequest.method?.toLowerCase() === 'get';
+    const isRetryable = !error.response && (error.code === 'ERR_NETWORK' || error.code === 'ECONNABORTED');
+    const retryCount = (originalRequest as any)._retryCount ?? 0;
+
+    if (isGetRequest && isRetryable && retryCount < GET_RETRY_COUNT && !originalRequest.url?.includes('/api/auth/')) {
+      (originalRequest as any)._retryCount = retryCount + 1;
+      console.warn(`[Request] Retrying GET ${originalRequest.url} (attempt ${retryCount + 1}/${GET_RETRY_COUNT})`);
+      await new Promise(resolve => setTimeout(resolve, GET_RETRY_DELAY));
+      return request(originalRequest);
+    }
+
     // 排除不需要刷新 token 的请求
     const isAuthRequest = originalRequest.url?.includes('/api/auth/refresh')
       || originalRequest.url?.includes('/api/auth/login')
@@ -160,6 +176,18 @@ request.interceptors.response.use(
       return Promise.reject(error);
     }
 
+    // 网络错误(无响应):不弹错误弹窗,仅在控制台记录,避免后端未就绪时频繁弹窗干扰用户
+    if (!error.response && error.code === 'ERR_NETWORK') {
+      console.warn('[Request] Network error (backend may be unavailable):', error.message, error.config?.url);
+      return Promise.reject(error);
+    }
+
+    // 请求超时:不弹错误弹窗,仅在控制台记录
+    if (error.code === 'ECONNABORTED' && error.message?.includes('timeout')) {
+      console.warn('[Request] Request timeout:', error.config?.url);
+      return Promise.reject(error);
+    }
+
     // 其他错误
     const message = error.response?.data?.error?.message
       || error.response?.data?.message

+ 50 - 2
client/src/views/Profile/index.vue

@@ -145,8 +145,56 @@ function formatDate(date?: string | null) {
 }
 
 function handleAvatarChange(file: UploadFile) {
-  // TODO: 上传头像
-  ElMessage.info('头像上传功能开发中');
+  if (!file.raw) return;
+
+  // 校验文件类型
+  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+  if (!allowedTypes.includes(file.raw.type)) {
+    ElMessage.error('仅支持 JPG、PNG、GIF、WebP 格式的图片');
+    return;
+  }
+
+  // 校验文件大小(2MB)
+  if (file.raw.size > 2 * 1024 * 1024) {
+    ElMessage.error('头像图片不能超过 2MB');
+    return;
+  }
+
+  const formData = new FormData();
+  formData.append('image', file.raw);
+
+  // 使用 axios 直接上传(需要手动设置 baseURL)
+  const serverStore = (await import('@/stores/server')).useServerStore();
+  const baseUrl = serverStore.currentServer?.url;
+  if (!baseUrl) {
+    ElMessage.error('未配置服务器地址');
+    return;
+  }
+
+  const authStoreLocal = (await import('@/stores/auth')).useAuthStore();
+
+  fetch(`${baseUrl}/api/upload/avatar`, {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${authStoreLocal.accessToken}`,
+    },
+    body: formData,
+  })
+    .then(async (res) => {
+      if (!res.ok) throw new Error('上传失败');
+      const json = await res.json();
+      if (!json.success) throw new Error(json.message || '上传失败');
+      return json.data;
+    })
+    .then(async (data: { path: string }) => {
+      // 更新用户头像
+      const updatedUser = await authApi.updateProfile({ avatarUrl: data.path });
+      authStore.user = updatedUser;
+      ElMessage.success('头像更新成功');
+    })
+    .catch(() => {
+      ElMessage.error('头像上传失败');
+    });
 }
 
 async function saveProfile() {

+ 5 - 5
client/src/views/Schedule/index.vue

@@ -82,11 +82,11 @@
         </el-form-item>
         <el-form-item label="重复方式">
           <el-radio-group v-model="form.repeatType">
-            <el-radio-button value="once">仅一次</el-radio-button>
-            <el-radio-button value="daily">每天</el-radio-button>
-            <el-radio-button value="weekday">工作日</el-radio-button>
-            <el-radio-button value="weekly">每周</el-radio-button>
-            <el-radio-button value="custom">自定义</el-radio-button>
+            <el-radio-button label="once">仅一次</el-radio-button>
+            <el-radio-button label="daily">每天</el-radio-button>
+            <el-radio-button label="weekday">工作日</el-radio-button>
+            <el-radio-button label="weekly">每周</el-radio-button>
+            <el-radio-button label="custom">自定义</el-radio-button>
           </el-radio-group>
         </el-form-item>
         <el-form-item v-if="form.repeatType === 'weekly'" label="周几">

+ 89 - 1
client/src/views/Settings/index.vue

@@ -113,6 +113,37 @@
             </el-table-column>
           </el-table>
         </div>
+
+        <!-- 新增用户对话框 -->
+        <el-dialog
+          v-model="showAddUserDialog"
+          title="新增用户"
+          width="480px"
+          @closed="resetUserForm"
+        >
+          <el-form :model="userForm" :rules="userFormRules" ref="userFormRef" label-width="80px">
+            <el-form-item label="用户名" prop="username">
+              <el-input v-model="userForm.username" placeholder="请输入用户名" />
+            </el-form-item>
+            <el-form-item label="密码" prop="password">
+              <el-input v-model="userForm.password" type="password" show-password placeholder="请输入密码" />
+            </el-form-item>
+            <el-form-item label="邮箱" prop="email">
+              <el-input v-model="userForm.email" placeholder="请输入邮箱(可选)" />
+            </el-form-item>
+            <el-form-item label="角色" prop="role">
+              <el-select v-model="userForm.role" style="width: 100%">
+                <el-option label="运营" value="operator" />
+                <el-option label="编辑" value="editor" />
+                <el-option label="管理员" value="admin" />
+              </el-select>
+            </el-form-item>
+          </el-form>
+          <template #footer>
+            <el-button @click="showAddUserDialog = false">取消</el-button>
+            <el-button type="primary" @click="handleAddUser" :loading="addingUser">确定</el-button>
+          </template>
+        </el-dialog>
       </el-tab-pane>
       
       <el-tab-pane label="系统状态" name="status">
@@ -142,7 +173,7 @@
 
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue';
-import { ElMessage, ElMessageBox } from 'element-plus';
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import request from '@/api/request';
 import { useAuthStore } from '@/stores/auth';
 import type { User } from '@media-manager/shared';
@@ -170,6 +201,63 @@ const pythonCheckResult = ref<any | null>(null);
 
 const users = ref<User[]>([]);
 const showAddUserDialog = ref(false);
+const addingUser = ref(false);
+const userFormRef = ref<FormInstance>();
+
+const userForm = reactive({
+  username: '',
+  password: '',
+  email: '',
+  role: 'operator',
+});
+
+const userFormRules: FormRules = {
+  username: [
+    { required: true, message: '请输入用户名', trigger: 'blur' },
+    { min: 3, max: 50, message: '用户名长度为3-50个字符', trigger: 'blur' },
+  ],
+  password: [
+    { required: true, message: '请输入密码', trigger: 'blur' },
+    { min: 6, message: '密码长度至少6个字符', trigger: 'blur' },
+  ],
+  email: [
+    { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
+  ],
+  role: [
+    { required: true, message: '请选择角色', trigger: 'change' },
+  ],
+};
+
+function resetUserForm() {
+  userForm.username = '';
+  userForm.password = '';
+  userForm.email = '';
+  userForm.role = 'operator';
+  userFormRef.value?.resetFields();
+}
+
+async function handleAddUser() {
+  if (!userFormRef.value) return;
+  const valid = await userFormRef.value.validate().catch(() => false);
+  if (!valid) return;
+
+  addingUser.value = true;
+  try {
+    await request.post('/api/users', {
+      username: userForm.username,
+      password: userForm.password,
+      email: userForm.email || undefined,
+      role: userForm.role,
+    });
+    ElMessage.success('用户创建成功');
+    showAddUserDialog.value = false;
+    loadUsers();
+  } catch {
+    // 错误已由拦截器处理
+  } finally {
+    addingUser.value = false;
+  }
+}
 
 const systemStatus = reactive({
   database: 'disconnected',

+ 22 - 0
server/src/routes/upload.ts

@@ -91,4 +91,26 @@ router.post(
   })
 );
 
+// 上传头像
+router.post(
+  '/avatar',
+  handleMulterError(uploadImage),
+  asyncHandler(async (req, res) => {
+    if (!req.file) {
+      throw new AppError('未上传文件', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
+    }
+    
+    res.json({
+      success: true,
+      data: {
+        filename: req.file.filename,
+        originalname: req.file.originalname,
+        path: `/uploads/images/${req.file.filename}`,
+        size: req.file.size,
+        mimetype: req.file.mimetype,
+      },
+    });
+  })
+);
+
 export default router;

+ 1 - 4
server/src/services/WorkDayStatisticsService.ts

@@ -1835,15 +1835,12 @@ export class WorkDayStatisticsService {
    */
   private formatUpdateTime(date: Date): string {
     const y = date.getFullYear();
-    const nowYear = new Date().getFullYear();
     const month = String(date.getMonth() + 1).padStart(2, '0');
     const day = String(date.getDate()).padStart(2, '0');
     const hours = String(date.getHours()).padStart(2, '0');
     const minutes = String(date.getMinutes()).padStart(2, '0');
 
-    if (y === nowYear) {
-      return `${month}-${day} ${hours}:${minutes}`;
-    }
+    // 始终返回完整的 YYYY-MM-DD HH:mm 格式,避免前端 dayjs 解析省略年份时被误解析为 2001 年
     return `${y}-${month}-${day} ${hours}:${minutes}`;
   }
 }