NewbieTaskDialog.vue 8.31 KB
<template>
  <div
    v-if="visible"
    class="newbie-task-container"
    :style="containerStyle"
    @mousedown.stop
  >
    <div class="newbie-task-header" @mousedown="onHeaderMouseDown">
      <span class="header-title">{{ t('newbieTask.title') }}</span>
      <el-icon class="close-icon" @click.stop="close"><Close /></el-icon>
    </div>

    <div class="newbie-task-body">
      <div class="subtitle">
        {{ t('newbieTask.subtitle', { count: totalReward }) }}
      </div>

      <div class="progress-info">
        <div class="earned-info">
          <span class="label">{{ t('newbieTask.earned') }}</span>
          <span class="value">{{ earnedReward }}</span>
        </div>
        <div class="total-info">
          <span class="label">{{ t('newbieTask.total', { count: totalReward }) }}</span>
        </div>
      </div>

      <div class="task-list">
        <div
          v-for="task in tasks"
          :key="task.key"
          class="task-item"
          :class="{ completed: task.completed }"
        >
          <div class="task-icon">
            <div class="circle-icon">
              <el-icon v-if="task.completed" class="check-inner"><Check /></el-icon>
            </div>
          </div>
          <div class="task-name">{{ task.name }}</div>
          <div class="task-reward">+{{ task.beanQuantity }}</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Check, Close } from '@element-plus/icons-vue';
import { getNewbieTasksConfig, getNewbieTasksGrantedList, type NewbieTaskConfigItem } from '@/api/pay';

const { t } = useI18n();
const visible = ref(false);
const tasks = ref<(NewbieTaskConfigItem & { completed: boolean })[]>([]);

// 位置与拖拽逻辑(与消息中心组件保持一致风格)
const STORAGE_KEY = 'newbie_task_pos';
const position = reactive<{ x: number; y: number }>({ x: 0, y: 0 });
const isDragging = ref(false);
const dragOffset = reactive({ x: 0, y: 0 });

const containerStyle = computed(() => ({
  left: `${position.x}px`,
  top: `${position.y}px`
}));

// ... (computed 保持不变) ...

const handleTaskCompletedEvent = () => {
  if (visible.value) {
    loadData();
  }
};

const handleKeyDown = (e: KeyboardEvent) => {
  if (e.key === 'Escape' && visible.value) {
    close();
  }
};

const onHeaderMouseDown = (e: MouseEvent) => {
  isDragging.value = true;
  dragOffset.x = e.clientX - position.x;
  dragOffset.y = e.clientY - position.y;
  window.addEventListener('mousemove', onMouseMove);
  window.addEventListener('mouseup', onMouseUp);
};

const onMouseMove = (e: MouseEvent) => {
  if (!isDragging.value) return;
  const maxX = window.innerWidth - 380;
  const maxY = window.innerHeight - 460;
  position.x = Math.max(0, Math.min(e.clientX - dragOffset.x, maxX));
  position.y = Math.max(0, Math.min(e.clientY - dragOffset.y, maxY));
};

const onMouseUp = () => {
  if (isDragging.value) {
    localStorage.setItem(
      STORAGE_KEY,
      JSON.stringify({ x: position.x, y: position.y })
    );
  }
  isDragging.value = false;
  window.removeEventListener('mousemove', onMouseMove);
  window.removeEventListener('mouseup', onMouseUp);
};

onMounted(() => {
  window.addEventListener('newbie-task-completed', handleTaskCompletedEvent);
  window.addEventListener('keydown', handleKeyDown);
});

onUnmounted(() => {
  window.removeEventListener('newbie-task-completed', handleTaskCompletedEvent);
  window.removeEventListener('keydown', handleKeyDown);
  window.removeEventListener('mousemove', onMouseMove);
  window.removeEventListener('mouseup', onMouseUp);
});

const totalReward = computed(() => {
  return tasks.value.reduce((sum, task) => sum + task.beanQuantity, 0);
});

const earnedReward = computed(() => {
  return tasks.value
    .filter(task => task.completed)
    .reduce((sum, task) => sum + task.beanQuantity, 0);
});

const loadData = async () => {
  try {
    // 即使 grantedList 报错,配置接口 res 也能拿到
    let configTasks: NewbieTaskConfigItem[] = [];
    let grantedKeys: string[] = [];

    try {
      const configRes = await getNewbieTasksConfig();
      if (configRes.data && configRes.data.tasks) {
        configTasks = configRes.data.tasks;
      }
    } catch (e) {
      console.error('获取任务配置失败:', e);
    }

    try {
      const grantedRes = await getNewbieTasksGrantedList();
      grantedKeys = grantedRes.data || [];
    } catch (e) {
      console.warn('获取已领奖列表失败 (可能是后端 500):', e);
      // 如果后端报 500,我们可以暂时认为都没有领取,以便显示任务列表
    }

    if (configTasks.length > 0) {
      tasks.value = configTasks.map(task => ({
        ...task,
        completed: grantedKeys.includes(task.key)
      }));
    }
  } catch (error) {
    console.error('加载新手任务数据失败:', error);
  }
};

const close = () => {
  visible.value = false;
};

const open = () => {
  visible.value = true;
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved) {
    try {
      const parsed = JSON.parse(saved) as { x?: number; y?: number };
      if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
        position.x = parsed.x;
        position.y = parsed.y;
      }
    } catch (e) {
      console.warn('解析新手任务位置失败,使用默认位置', e);
    }
  }

  if (!saved) {
    const sidebar = document.querySelector('.nav-rail');
    const sidebarWidth = sidebar ? sidebar.clientWidth : 54;
    // 第一次打开时:更贴近侧边栏(向左收紧间距)
    position.x = sidebarWidth;
    position.y = Math.max(0, window.innerHeight - 440);
  }

  loadData();
};

defineExpose({
  open,
  close,
  isOpen: () => visible.value
});
</script>

<style scoped>
.newbie-task-container {
  position: fixed;
  width: 360px;
  max-height: 460px;
  background: var(--color-bg, #fff);
  border-radius: 12px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
  z-index: 9999;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.newbie-task-header {
  padding: 14px 16px;
  background: #fcfcfc;
  border-bottom: 1px solid #f2f2f2;
  display: flex;
  justify-content: space-between;
  align-items: center;
  cursor: move;
  flex-shrink: 0;
}

.header-title {
  font-size: 16px;
  font-weight: 600;
  color: #303133;
}

.close-icon {
  cursor: pointer;
  color: #909399;
  font-size: 18px;
  transition: all 0.2s;
}

.close-icon:hover {
  color: #f56c6c;
}

.newbie-task-body {
  padding: 16px 16px 18px;
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
}

.subtitle {
  font-size: 14px;
  color: #909399;
}

.progress-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
  margin-bottom: 20px;
  font-size: 14px;
}

.earned-info .label {
  color: #606266;
  font-size: 14px;
  margin-right: 8px;
}

.earned-info .value {
  font-size: 18px;
  font-weight: 600;
  color: #303133;
}

.total-info .label {
  color: #606266;
  font-size: 14px;
}

.task-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  flex: 1;
  overflow-y: auto;
}

.task-item {
  display: flex;
  align-items: center;
  padding: 12px;
  background: #f5f7fa;
  border-radius: 8px;
  transition: all 0.2s;
}

.task-item.completed {
  opacity: 0.8;
}

.task-icon {
  margin-right: 12px;
}

.circle-icon {
  width: 14px;
  height: 14px;
  border: 1px solid #dcdfe6;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.completed .circle-icon {
  border-color: #67c23a;
  background: white;
}

.check-inner {
  color: #67c23a;
  font-size: 12px;
  font-weight: bold;
}

.task-name {
  flex: 1;
  font-size: 14px;
  color: #303133;
  font-weight: 500;
}

.task-reward {
  font-size: 14px;
  font-weight: 600;
  color: #303133;
  margin: 0 12px;
}

.completed-icon {
  color: #67c23a;
}

/* Dark theme support */
.theme-dark .newbie-task-container {
  background: #2d2d2d;
  border-color: #4c4d4f;
}

.theme-dark .newbie-task-header {
  background: #333333;
  border-bottom-color: #4c4d4f;
}

.theme-dark .header-title {
  color: #e4e7ed;
}

.theme-dark .task-item {
  background: #37373d;
}

.theme-dark .task-name,
.theme-dark .task-reward,
.theme-dark .earned-info .value {
  color: #e4e7ed;
}

.theme-dark .circle-icon {
  border-color: #4c4d4f;
}
</style>