yeg
2026-09-09 c52ac07b45015b16aa0bf8b959f559cec6f5a38f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCurrentUser } from '@/utils/auth'
import { useTodos, type TodoRecord, type TodoStatus } from '@/stores/todos'
import { getEnabledUserOptions } from '@/api/users'
 
interface FilterState {
  owner: string
  status: string
  dateRange: [string, string] | null
}
 
const owners = ref<{ id:number; name:string }[]>([])
const statuses: TodoStatus[] = ['待处理', '已处理']
const { records, createTodo, handleTodo, removeTodo, load } = useTodos()
const currentOwner = computed(() => getCurrentUser()?.displayName || getCurrentUser()?.username || 'Admin')
const filters = reactive<FilterState>({ owner: '', status: '', dateRange: null })
const appliedFilters = ref<FilterState>({ owner: '', status: '', dateRange: null })
const currentPage = ref(1)
const pageSize = ref(10)
const createDialogVisible = ref(false)
const createForm = reactive({ content: '', owner: '' })
onMounted(async () => { await load(); owners.value = await getEnabledUserOptions() })
 
const filteredRecords = computed(() => {
  const query = appliedFilters.value
  return records.value
    .filter(item => item.owner === currentOwner.value)
    .filter(item => !query.owner || query.owner === '全部' || item.owner === query.owner)
    .filter(item => !query.status || query.status === '全部' || item.status === query.status)
    .filter(item => !query.dateRange || (item.operatedAt.slice(0, 10) >= query.dateRange[0] && item.operatedAt.slice(0, 10) <= query.dateRange[1]))
    .sort((a, b) => b.operatedAt.localeCompare(a.operatedAt))
})
 
const pagedRecords = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  return filteredRecords.value.slice(start, start + pageSize.value)
})
 
function applySearch() {
  appliedFilters.value = { ...filters, dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null }
  currentPage.value = 1
  ElMessage.success(`已找到 ${filteredRecords.value.length} 条待办`)
}
 
function resetSearch() {
  Object.assign(filters, { owner: '', status: '', dateRange: null })
  appliedFilters.value = { owner: '', status: '', dateRange: null }
  currentPage.value = 1
}
 
function openCreate() {
  Object.assign(createForm, { content: '', owner: '' })
  createDialogVisible.value = true
}
 
function submitCreate() {
  const content = createForm.content.trim()
  if (!content) { ElMessage.warning('请输入待办内容'); return }
  if (!createForm.owner) { ElMessage.warning('请选择负责人'); return }
  const owner = owners.value.find(item => item.name === createForm.owner)
  if (!owner) { ElMessage.warning('负责人不存在'); return }
  createTodo({ content, ownerId: owner.id, owner: owner.name })
  createDialogVisible.value = false
  currentPage.value = 1
  ElMessage.success('待办已新增')
}
 
async function processTodo(item: TodoRecord) {
  if (item.status === '已处理' || item.owner !== currentOwner.value) return
  try {
    const result = await ElMessageBox.prompt(`请填写“${item.content}”的处理说明。`, '处理待办', {
      confirmButtonText: '完成处理', cancelButtonText: '取消', inputType: 'textarea', inputPlaceholder: '请输入处理说明',
      inputValidator: value => value.trim() ? true : '请输入处理说明',
    })
    handleTodo(item.id, result.value.trim())
    ElMessage.success('待办已处理')
  } catch {
    // 用户取消处理。
  }
}
 
async function deleteTodo(item: TodoRecord) {
  try {
    await ElMessageBox.confirm(`删除待办“${item.content}”后无法恢复,是否继续?`, '确认删除待办', {
      confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm',
    })
    if (item.owner !== currentOwner.value) return
    removeTodo(item.id)
    currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(filteredRecords.value.length / pageSize.value)))
    ElMessage.success('待办已删除')
  } catch {
    // 用户取消删除。
  }
}
</script>
 
<template>
  <div class="todo-page">
    <section class="filter-panel" aria-label="待办查询条件">
      <header class="section-heading"><div><i></i><strong>查询条件</strong><span>TODO FILTER CONDITIONS</span></div><small>共 {{ records.length }} 条待办</small></header>
      <div class="filter-grid">
        <label><span>负责人</span><el-select v-model="filters.owner" clearable placeholder="全部"><el-option label="全部" value="全部" /><el-option v-for="item in owners" :key="item.id" :label="item.name" :value="item.name" /></el-select></label>
        <label><span>状态</span><el-select v-model="filters.status" clearable placeholder="全部"><el-option label="全部" value="全部" /><el-option v-for="item in statuses" :key="item" :label="item" :value="item" /></el-select></label>
        <label class="date-filter"><span>操作时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" unlink-panels /></label>
        <div class="filter-actions"><button class="primary-button" type="button" @click="applySearch"><el-icon><Search /></el-icon>查询</button><button class="secondary-button" type="button" @click="resetSearch"><el-icon><RefreshLeft /></el-icon>重置</button></div>
      </div>
    </section>
 
    <section class="result-panel">
      <div class="result-toolbar"><div><i></i><strong>待办列表</strong><span>{{ filteredRecords.length }} RESULTS · 按操作时间倒序</span></div><button class="primary-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新增待办</button></div>
      <div class="table-wrap">
        <table>
          <thead><tr><th>待办ID</th><th>待办内容</th><th>负责人</th><th>状态</th><th class="action-column">操作</th></tr></thead>
          <tbody>
            <tr v-for="item in pagedRecords" :key="item.id"><td class="id-cell">#{{ item.id }}</td><td :title="item.content"><strong>{{ item.content }}</strong></td><td>{{ item.owner }}</td><td><span class="status-tag" :class="item.status === '待处理' ? 'pending' : 'handled'"><i></i>{{ item.status }}</span></td><td class="action-column"><div class="row-actions"><button v-if="item.status === '待处理'" type="button" @click="processTodo(item)">处理待办</button><button class="delete-action" type="button" @click="deleteTodo(item)">删除</button></div></td></tr>
            <tr v-if="!pagedRecords.length"><td colspan="5" class="empty-cell">暂无符合条件的待办</td></tr>
          </tbody>
        </table>
      </div>
      <footer class="pagination-bar"><span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="filteredRecords.length" layout="sizes, prev, pager, next" background /></footer>
    </section>
 
    <el-dialog v-model="createDialogVisible" title="新增待办" width="600px" align-center destroy-on-close>
      <div class="create-form"><label><span>待办内容</span><el-input v-model="createForm.content" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="请输入待办内容" /></label><label><span>负责人</span><el-select v-model="createForm.owner" filterable placeholder="请选择负责人"><el-option v-for="item in owners" :key="item.id" :label="item.name" :value="item.name" /></el-select></label></div>
      <template #footer><button class="secondary-button" type="button" @click="createDialogVisible = false">取消</button><button class="primary-button" type="button" @click="submitCreate"><el-icon><Check /></el-icon>确定新增</button></template>
    </el-dialog>
  </div>
</template>
 
<style scoped>
.todo-page{width:100%}.primary-button,.secondary-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.primary-button:hover{background:#176fcb;border-color:#176fcb}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.secondary-button:hover{color:var(--blue);border-color:#9dc8f5}.filter-panel,.result-panel{background:#fff;border:1px solid #d8e1e9}.filter-panel{margin-bottom:16px}.section-heading,.result-toolbar{display:flex;align-items:center;justify-content:space-between;background:var(--surface-soft);border-bottom:1px solid var(--line)}.section-heading{height:44px;padding:0 16px}.section-heading>div,.result-toolbar>div{display:flex;align-items:center;gap:8px}.section-heading>div>i,.result-toolbar>div:first-child>i{width:3px;height:16px;background:#2b78a3}.section-heading strong,.result-toolbar strong{color:var(--ink);font-size:15px}.section-heading span,.result-toolbar span{color:#81909d;font-size:10px}.section-heading small{color:#647380;font-size:11px}.filter-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px 18px;padding:16px}.filter-grid label{width:100%;min-width:0;display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:8px}.filter-grid label>span{color:#52606d;font-size:12px}.filter-grid :deep(.el-select),.filter-grid :deep(.el-date-editor){width:100%}.filter-grid :deep(.el-input__wrapper),.filter-grid :deep(.el-select__wrapper){min-height:34px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.filter-actions{display:flex;justify-content:flex-end;gap:8px}.result-toolbar{height:50px;padding:0 16px}.table-wrap{overflow-x:auto}table{width:100%;min-width:1000px;table-layout:fixed;border-collapse:collapse;color:#52606d;font-size:13px}th,td{height:50px;padding:0 12px;border-right:1px solid #dde7f0;border-bottom:1px solid #dde7f0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}th{height:40px;background:#eaf4ff;border-color:#c7e0ff;font-size:12px}th:nth-child(1){width:120px}th:nth-child(2){width:480px}th:nth-child(3){width:130px}th:nth-child(4){width:120px}th:last-child{width:180px}td strong{color:#26333f}.id-cell{color:#647380;font-family:'Cascadia Mono',Consolas,monospace;font-size:12px}.status-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 7px;border:1px solid;font-size:11px;line-height:1}.status-tag i{width:5px;height:5px;border-radius:50%;background:currentColor}.status-tag.pending{color:#b06a18;background:#fff8e9;border-color:#efd59e}.status-tag.handled{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.action-column{position:sticky;right:0;z-index:2;background:#fff;box-shadow:inset 1px 0 0 #b4c7d9,-8px 0 14px rgba(23,49,74,.12);text-align:center}.action-column{overflow:visible}th.action-column{z-index:3;background:#eaf4ff}.row-actions{display:flex;justify-content:center;gap:16px}.row-actions button{border:0;color:var(--blue);background:transparent;font-size:12px;cursor:pointer}.row-actions .delete-action{color:#ad4a4a}tbody tr:hover,tbody tr:hover td.action-column{background:#f7fbff}.empty-cell{height:160px;color:#8795a3;text-align:center}.pagination-bar{min-height:58px;display:flex;align-items:center;justify-content:space-between;padding:8px 14px 8px 16px;border-top:1px solid var(--line)}.pagination-bar>span{color:#7b8794;font-size:11px}.create-form{display:grid;gap:18px}.create-form label{display:grid;grid-template-columns:78px minmax(0,1fr);align-items:start;gap:12px;color:#52606d;font-size:12px}.create-form label>span{padding-top:9px}.create-form :deep(.el-select){width:100%}.create-form :deep(.el-textarea__inner),.create-form :deep(.el-select__wrapper){border-radius:3px;background:#f8fafc}@media(max-width:1100px){.filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:680px){.filter-grid{grid-template-columns:1fr}.filter-actions{justify-content:stretch}.filter-actions button{flex:1}.section-heading span,.result-toolbar span{display:none}.result-toolbar{height:auto;min-height:50px;gap:10px;padding-top:8px;padding-bottom:8px}.pagination-bar{align-items:flex-start;flex-direction:column;gap:10px}.create-form label{grid-template-columns:1fr}.create-form label>span{padding-top:0}}
</style>