qiangdanxitong/frontend/src/components/DailyProgressReminder.vue

834 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-modal
v-model:open="visible"
:width="680"
:closable="true"
:maskClosable="false"
:keyboard="true"
:footer="null"
centered
class="daily-progress-modal"
@cancel="handleClose"
>
<!-- 自定义标题 -->
<template #title>
<div class="modal-title">
<span class="title-icon">📋</span>
<span class="title-text">每日任务进度更新</span>
<a-button type="text" class="skip-btn" @click="handleSkip">
稍后提醒
</a-button>
</div>
</template>
<div class="daily-progress-reminder">
<!-- 提示信息 -->
<div class="reminder-banner">
<div class="banner-icon">
<Icon name="alert-circle" size="md" color="#F59E0B" />
</div>
<div class="banner-content">
<div class="banner-title">请更新您今天的任务进度</div>
<div class="banner-desc">每日 17:30 后首次登录需要更新进行中的任务进度</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<a-spin size="large" />
<p class="loading-text">正在加载任务列表...</p>
</div>
<!-- 无任务状态 -->
<div v-else-if="tasks.length === 0" class="no-tasks">
<div class="no-tasks-icon">
<Icon name="check-circle" size="xl" color="#31f08b" />
</div>
<div class="no-tasks-title">太棒了!</div>
<div class="no-tasks-desc">您目前没有进行中的任务</div>
<a-button type="primary" size="large" @click="handleNoTasksConfirm">
<Icon name="check" size="sm" style="margin-right: 6px;" />
确认并关闭
</a-button>
</div>
<!-- 任务列表 -->
<div v-else class="tasks-container">
<div class="tasks-list">
<div v-for="(task, index) in tasks" :key="task.id" class="task-card">
<!-- 任务序号标签 -->
<div class="task-index">{{ index + 1 }}</div>
<!-- 任务信息 -->
<div class="task-info">
<div class="task-header">
<div class="task-title">{{ task.title }}</div>
<div class="task-no">{{ task.taskNo }}</div>
</div>
<div class="task-time">
<div class="time-item">
<Icon name="calendar" size="xs" />
<span class="time-label">开始:</span>
<span class="time-value">{{ formatDate(task.startTime) }}</span>
</div>
<div class="time-item deadline">
<Icon name="clock" size="xs" />
<span class="time-label">截止:</span>
<span class="time-value">{{ formatDate(task.deadline) }}</span>
</div>
</div>
</div>
<!-- 进度更新区域 -->
<div class="progress-section">
<div class="progress-header">
<span class="progress-title">更新进度</span>
<span class="progress-hint">当前: {{ task.progress || 0 }}%</span>
</div>
<div class="progress-control">
<div class="slider-wrapper">
<a-slider
v-model:value="task.newProgress"
:min="0"
:max="100"
:step="5"
:tooltip="{ formatter: (value: number) => `${value}%` }"
/>
<div class="slider-labels">
<span class="label-start">0%</span>
<span class="label-current" :style="{ left: `${task.progress || 0}%` }">
上次 {{ task.progress || 0 }}%
</span>
<span class="label-end">100%</span>
</div>
</div>
<div class="input-wrapper">
<a-input-number
v-model:value="task.newProgress"
:min="0"
:max="100"
:precision="0"
:controls="true"
/>
<span class="input-suffix">%</span>
</div>
</div>
<!-- 备注输入 -->
<div class="remark-section">
<a-textarea
v-model:value="task.progressRemark"
placeholder="简要说明今日进展(选填)"
:rows="2"
:maxlength="200"
show-count
/>
</div>
<!-- 错误提示 -->
<div v-if="task.error" class="error-tip">
<Icon name="alert-circle" size="xs" />
{{ task.error }}
</div>
</div>
</div>
</div>
<!-- 提交按钮 -->
<div class="submit-section">
<a-button
type="primary"
size="large"
block
:loading="submitting"
@click="handleSubmit"
>
<template #icon>
<Icon name="check" size="sm" />
</template>
提交所有任务进度
</a-button>
<div class="submit-tip">* 必须更新所有任务进度才能关闭此弹窗</div>
</div>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import Icon from '@/components/Icon.vue'
import * as taskApi from '@/api/task'
import dayjs from 'dayjs'
interface TaskProgress {
id: number
taskNo: string
title: string
startTime?: string
deadline?: string
progress?: number
newProgress: number
progressRemark?: string
error?: string
}
const visible = ref(false)
const loading = ref(false)
const submitting = ref(false)
const tasks = ref<TaskProgress[]>([])
// 检查是否需要显示提醒
const checkAndShow = async () => {
// 检查今天是否已填写
const today = dayjs().format('YYYY-MM-DD')
const lastUpdateDate = localStorage.getItem('lastProgressUpdateDate')
if (lastUpdateDate === today) {
return // 今天已填写,不再提醒
}
// 检查当前时间是否在17:30之后
const currentTime = dayjs()
const currentHour = currentTime.hour()
const currentMinute = currentTime.minute()
// 判断是否在17:30之后
if (currentHour < 17 || (currentHour === 17 && currentMinute < 30)) {
return // 还未到17:30,不提醒
}
// 加载进行中的任务
await loadInProgressTasks()
}
// 加载进行中的任务
const loadInProgressTasks = async () => {
loading.value = true
visible.value = true
try {
const response = await taskApi.getMyInProgressTasks()
if (response.code === 200) {
const taskList = response.data || []
tasks.value = taskList.map((task: any) => ({
...task,
newProgress: task.progress || 0,
progressRemark: ''
}))
}
} catch (error) {
console.error('加载任务失败:', error)
message.error('加载任务列表失败')
} finally {
loading.value = false
}
}
// 格式化日期
const formatDate = (date: string | undefined) => {
if (!date) return '--'
return dayjs(date).format('YYYY-MM-DD HH:mm')
}
// 处理无任务的确认
const handleNoTasksConfirm = () => {
const today = dayjs().format('YYYY-MM-DD')
localStorage.setItem('lastProgressUpdateDate', today)
visible.value = false
message.success('确认成功')
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
// 稍后提醒(跳过本次)
const handleSkip = () => {
visible.value = false
message.info('已跳过,下次登录时将再次提醒')
}
// 提交所有任务进度
const handleSubmit = async () => {
// 验证:新进度不能小于当前进度
const hasError = tasks.value.some(task => {
if (task.newProgress < (task.progress || 0)) {
task.error = '新进度不能小于当前进度'
return true
}
task.error = ''
return false
})
if (hasError) {
message.warning('请检查所有任务进度')
return
}
// 过滤出有更新的任务
const updatedTasks = tasks.value.filter(task => task.newProgress !== task.progress)
submitting.value = true
try {
// 批量更新进度(只更新有变化的任务)
const updates = updatedTasks.map(task => ({
taskId: task.id,
progress: task.newProgress,
remark: task.progressRemark
}))
// 如果没有任何更新,直接关闭
if (updates.length === 0) {
const today = dayjs().format('YYYY-MM-DD')
localStorage.setItem('lastProgressUpdateDate', today)
message.success('已确认今日进度')
visible.value = false
return
}
const response = await taskApi.batchUpdateProgress(updates)
if (response.code === 200) {
// 记录今天已填写
const today = dayjs().format('YYYY-MM-DD')
localStorage.setItem('lastProgressUpdateDate', today)
message.success('进度更新成功!')
visible.value = false
}
} catch (error: any) {
console.error('更新进度失败:', error)
message.error(error.message || '更新进度失败')
} finally {
submitting.value = false
}
}
// 暴露方法供外部调用
defineExpose({
checkAndShow
})
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
// 模态框标题
.modal-title {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
.title-icon {
font-size: 20px;
}
.title-text {
font-size: 18px;
font-weight: 600;
color: $text-primary;
flex: 1;
}
.skip-btn {
color: $text-tertiary;
font-size: 13px;
height: auto;
padding: 4px 12px;
&:hover {
color: $text-secondary;
background: rgba(255, 255, 255, 0.05);
}
}
}
.daily-progress-reminder {
// 提示横幅
.reminder-banner {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(245, 158, 11, 0.06) 100%);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: 12px;
margin-bottom: 24px;
.banner-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(245, 158, 11, 0.15);
border-radius: 10px;
}
.banner-content {
flex: 1;
.banner-title {
font-size: 15px;
font-weight: 600;
color: $text-primary;
margin-bottom: 4px;
}
.banner-desc {
font-size: 13px;
color: $text-secondary;
}
}
}
// 加载状态
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
.loading-text {
margin-top: 16px;
color: $text-secondary;
font-size: 14px;
}
}
// 无任务状态
.no-tasks {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 50px 20px;
.no-tasks-icon {
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(49, 240, 139, 0.1);
border-radius: 50%;
margin-bottom: 20px;
}
.no-tasks-title {
font-size: 20px;
font-weight: 600;
color: $text-primary;
margin-bottom: 8px;
}
.no-tasks-desc {
font-size: 14px;
color: $text-secondary;
margin-bottom: 24px;
}
}
// 任务容器
.tasks-container {
max-height: 55vh;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
}
// 任务列表
.tasks-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 20px;
}
// 任务卡片
.task-card {
position: relative;
background: linear-gradient(145deg, $bg-card 0%, rgba($bg-card, 0.8) 100%);
border: 1px solid $border-primary;
border-radius: 14px;
padding: 20px 20px 20px 56px;
transition: all 0.2s ease;
&:hover {
border-color: rgba($brand-primary, 0.3);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
// 任务序号
.task-index {
position: absolute;
left: 16px;
top: 20px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $brand-primary 0%, darken($brand-primary, 10%) 100%);
border-radius: 8px;
font-size: 13px;
font-weight: 700;
color: #000;
}
// 任务信息
.task-info {
margin-bottom: 16px;
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
.task-title {
font-size: 15px;
font-weight: 600;
color: $text-primary;
line-height: 1.4;
flex: 1;
}
.task-no {
font-size: 11px;
color: $text-tertiary;
background: rgba(255, 255, 255, 0.06);
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
}
}
.task-time {
display: flex;
flex-wrap: wrap;
gap: 16px;
.time-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: $text-tertiary;
.time-label {
color: $text-tertiary;
}
.time-value {
color: $text-secondary;
}
&.deadline {
.time-value {
color: #F59E0B;
}
}
}
}
}
// 进度区域
.progress-section {
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
padding: 16px;
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.progress-title {
font-size: 13px;
font-weight: 600;
color: $text-primary;
}
.progress-hint {
font-size: 12px;
color: $brand-primary;
background: rgba($brand-primary, 0.1);
padding: 2px 10px;
border-radius: 10px;
}
}
.progress-control {
display: flex;
align-items: flex-start;
gap: 16px;
.slider-wrapper {
flex: 1;
position: relative;
padding-bottom: 24px;
.slider-labels {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
font-size: 11px;
color: $text-tertiary;
.label-current {
position: absolute;
transform: translateX(-50%);
color: rgba($brand-primary, 0.8);
white-space: nowrap;
}
}
}
.input-wrapper {
display: flex;
align-items: center;
gap: 6px;
.input-suffix {
font-size: 14px;
font-weight: 600;
color: $text-secondary;
}
}
}
.remark-section {
margin-top: 14px;
}
.error-tip {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
font-size: 13px;
color: #EF4444;
}
}
}
// 提交区域
.submit-section {
padding-top: 20px;
border-top: 1px solid $border-primary;
.submit-tip {
margin-top: 10px;
text-align: center;
font-size: 12px;
color: $text-tertiary;
}
}
}
// 深度样式覆盖
:deep(.ant-slider) {
.ant-slider-rail {
background: rgba(255, 255, 255, 0.1);
height: 8px;
border-radius: 4px;
}
.ant-slider-track {
background: linear-gradient(90deg, $brand-primary 0%, lighten($brand-primary, 10%) 100%);
height: 8px;
border-radius: 4px;
}
.ant-slider-handle {
width: 20px;
height: 20px;
margin-top: -6px;
background: $brand-primary;
border: 3px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
&:hover, &:focus {
border-color: #fff;
box-shadow: 0 2px 12px rgba($brand-primary, 0.5);
}
}
.ant-slider-dot {
display: none;
}
.ant-slider-mark {
display: none;
}
}
:deep(.ant-input-number) {
width: 72px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
.ant-input-number-input {
color: $text-primary;
font-weight: 600;
font-size: 15px;
text-align: center;
}
.ant-input-number-handler-wrap {
background: rgba(255, 255, 255, 0.06);
border-left: 1px solid rgba(255, 255, 255, 0.1);
.ant-input-number-handler {
border-color: rgba(255, 255, 255, 0.1);
.ant-input-number-handler-up-inner,
.ant-input-number-handler-down-inner {
color: $text-secondary;
}
&:hover {
.ant-input-number-handler-up-inner,
.ant-input-number-handler-down-inner {
color: $brand-primary;
}
}
}
}
&:hover, &:focus {
border-color: rgba($brand-primary, 0.5);
}
}
:deep(.ant-input) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: $text-primary;
&::placeholder {
color: $text-tertiary;
}
&:hover {
border-color: rgba(255, 255, 255, 0.2);
}
&:focus {
border-color: rgba($brand-primary, 0.5);
box-shadow: 0 0 0 2px rgba($brand-primary, 0.1);
}
}
:deep(.ant-input-textarea) {
.ant-input {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: $text-primary;
resize: none;
&::placeholder {
color: $text-tertiary;
}
&:hover {
border-color: rgba(255, 255, 255, 0.2);
}
&:focus {
border-color: rgba($brand-primary, 0.5);
box-shadow: 0 0 0 2px rgba($brand-primary, 0.1);
}
}
.ant-input-data-count {
color: $text-tertiary;
font-size: 11px;
}
}
:deep(.ant-spin) {
.ant-spin-dot-item {
background-color: $brand-primary;
}
}
:deep(.ant-btn-primary) {
height: 48px;
font-size: 15px;
font-weight: 600;
border-radius: 10px;
background: linear-gradient(135deg, $brand-primary 0%, darken($brand-primary, 8%) 100%);
border: none;
&:hover {
background: linear-gradient(135deg, lighten($brand-primary, 5%) 0%, $brand-primary 100%);
}
}
</style>
<style lang="scss">
// 全局模态框样式
.daily-progress-modal {
.ant-modal-content {
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
overflow: hidden;
}
.ant-modal-header {
background: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 20px 24px;
}
.ant-modal-title {
color: #fff;
}
.ant-modal-body {
padding: 24px;
}
}
</style>