feat(todo): 待办清单页面改版——卡片式列表+统计看板+飞书表格视图

- 卡片式任务列表:圆点勾选、优先级色点、截止日标签(逾期/今天高亮)
- 顶部统计看板:全部/未完成/已完成,点击卡片即筛选
- 快捷添加:回车即创建,可选优先级
- 新增卡片/表格视图切换,表格视图内嵌飞书表格(SHEET_URL 待配置)
- 手机端响应式适配(≤768px)
- 后端与接口层无改动
This commit is contained in:
zhangjiaxun
2026-10-17 09:09:32 +08:00
parent 2d6cac2d06
commit b5df0b2d3e
+569 -303
View File
@@ -1,378 +1,644 @@
<template> <template>
<div> <div class="todo-page">
<!-- 查询区域 start --> <!-- 顶部:标题 + 统计看板 -->
<div class="option"> <div class="todo-header">
<Input v-model="search.title" class="short-input" clearable placeholder="标题"/> <div class="todo-heading">
<Select v-model="search.status" class="short-input" clearable placeholder="状态"> <h2 class="todo-title">待办清单</h2>
<Option :value="0">未完成</Option> <span class="todo-date">{{ todayText }}</span>
<Option :value="1">已完成</Option> </div>
</Select> <div class="stat-cards" v-if="viewMode === 'card'">
<Select v-model="search.priority" class="short-input" clearable placeholder="优先级"> <div class="stat-card" :class="{ active: statusTab === 'all' }" @click="setTab('all')">
<Option :value="1">低</Option> <div class="stat-num">{{ stats.total }}</div>
<Option :value="2">中</Option> <div class="stat-label">全部</div>
<Option :value="3">高</Option> </div>
</Select> <div class="stat-card warn" :class="{ active: statusTab === 'undone' }" @click="setTab('undone')">
<Button class="free" type="primary" icon="ios-search" @click="getDataList()">查询</Button> <div class="stat-num">{{ stats.undone }}</div>
<Button class="free" type="primary" icon="md-refresh" @click="refresh()">重置</Button> <div class="stat-label">未完成</div>
</div>
<div class="stat-card ok" :class="{ active: statusTab === 'done' }" @click="setTab('done')">
<div class="stat-num">{{ stats.done }}</div>
<div class="stat-label">已完成</div>
</div>
</div>
</div> </div>
<!-- 查询区域 end -->
<!-- 列表区域 start --> <!-- 快捷添加(仅卡片视图) -->
<div class="table-button-list"> <div class="quick-add" v-if="viewMode === 'card'">
<Button class="free" type="info" icon="md-add" @click="toAdd">添加</Button> <Input v-model="quickTitle" class="quick-input" clearable size="large"
placeholder="想到什么就记下来,回车快速添加…" @on-enter="quickAdd"/>
<Select v-model="quickPriority" class="quick-priority" size="large" placement="bottom-end">
<Option :value="1">低优先级</Option>
<Option :value="2">中优先级</Option>
<Option :value="3">高优先级</Option>
</Select>
<Button type="primary" class="quick-btn" size="large" icon="md-add" @click="quickAdd">添加</Button>
</div> </div>
<Table :height="fixedHeader ? autoHeight : ''"
border ref="table"
:columns="listColumns"
:data="dataList"
:loading="tableLoading">
<template slot-scope="{ row }" slot="status">
<i-switch :value="row.status === 1" true-value="1" false-value="0"
@on-change="val => updateStatus(row, val)">
<span slot="open">完成</span>
<span slot="close">未完成</span>
</i-switch>
</template>
<template slot-scope="{ row }" slot="priority">
<Tag :color="priorityColor(row.priority)">{{ priorityName(row.priority) }}</Tag>
</template>
<template slot-scope="{ row, index }" slot="action">
<Button type="primary"
size="small"
style="margin-right: 5px"
icon="ios-create"
@click="toEdit(index)">编辑
</Button>
<Poptip
confirm
title="您确定要删除吗?"
:transfer="true"
@on-ok="toDelete(index)"
>
<Button type="error" size="small" icon="md-trash">删除</Button>
</Poptip>
</template>
</Table>
<div class="pagination">
<Page show-elevator show-sizer show-total
:total="pageTotal" :page-size="pageParam.pageSize"
:current="pageParam.pageNum"
@on-page-size-change="changePageSize" @on-change="changePage"></Page>
</div>
<!-- 列表区域 end -->
<!-- Modal区域 start --> <!-- 筛选栏 -->
<Modal v-model="modal" :title="modalTitle" :styles="{top: '20px'}" width="700" :mask-closable="false" <div class="filter-bar">
@on-cancel="cancel()"> <RadioGroup v-model="statusTab" type="button" button-style="solid" @on-change="onTabChange"
<Form :rules="ruleCustom" :model="formItem" ref="formItem" :label-width="110" label-position="right"> v-if="viewMode === 'card'">
<Row :gutter="32"> <Radio label="all">全部</Radio>
<Col span="24"> <Radio label="undone">未完成</Radio>
<FormItem label="标题" prop="title"> <Radio label="done">已完成</Radio>
<Input type="text" class="form-input" v-model="formItem.title" placeholder="请输入待办标题"/> </RadioGroup>
</FormItem> <span v-else class="sheet-hint">飞书共享表格,数据在飞书侧维护</span>
</Col> <div class="filter-right">
<Col span="12"> <!-- 视图切换:卡片 / 飞书表格 -->
<FormItem label="优先级" prop="priority"> <RadioGroup v-model="viewMode" type="button" class="view-switch">
<RadioGroup v-model="formItem.priority"> <Radio label="card"><Icon type="md-list-box"/> 卡片</Radio>
<Radio :label="1"><span>低</span></Radio> <Radio label="sheet"><Icon type="md-grid"/> 表格</Radio>
<Radio :label="2"><span>中</span></Radio> </RadioGroup>
<Radio :label="3"><span>高</span></Radio> <template v-if="viewMode === 'card'">
</RadioGroup> <Select v-model="search.priority" clearable class="filter-priority" placeholder="优先级" @on-change="onSearch">
</FormItem> <Option :value="1">低</Option>
</Col> <Option :value="2">中</Option>
<Col span="12"> <Option :value="3">高</Option>
<FormItem label="状态" prop="status"> </Select>
<RadioGroup v-model="formItem.status"> <Input v-model="search.title" class="filter-search" search clearable
<Radio :label="0"><span>未完成</span></Radio> placeholder="搜索标题,回车确认" @on-search="onSearch" @on-clear="onSearch"/>
<Radio :label="1"><span>已完成</span></Radio> </template>
</RadioGroup> </div>
</FormItem> </div>
</Col>
<Col span="12"> <!-- 飞书表格视图 -->
<FormItem label="截止日期" prop="dueDate"> <div v-if="viewMode === 'sheet'" class="sheet-view">
<DatePicker type="date" <iframe v-if="sheetUrl" :src="sheetUrl" class="sheet-frame" allowfullscreen></iframe>
class="form-input" <div v-else class="sheet-empty">
format="yyyy-MM-dd" <Icon type="md-grid" size="48"/>
v-model="formItem.dueDate" <p>尚未配置飞书表格链接</p>
placeholder="请选择截止日期"> <p class="sheet-tip">在代码顶部 SHEET_URL 填入飞书表格分享链接(表格需开启「获得链接的人可编辑」)</p>
</DatePicker> </div>
</FormItem> </div>
</Col>
<Col span="24"> <!-- 任务列表(卡片视图) -->
<FormItem label="备注" prop="remark"> <template v-if="viewMode === 'card'">
<Input type="textarea" :rows="3" maxlength="200" show-word-limit class="form-input" <Spin v-if="tableLoading" class="todo-spin"/>
v-model="formItem.remark" placeholder="选填:补充说明"/> <div v-else-if="dataList.length" class="todo-list">
</FormItem> <div v-for="(item, index) in dataList" :key="item.id"
</Col> class="todo-item" :class="{ 'is-done': item.status === 1 }">
</Row> <span class="check-circle" :title="item.status === 1 ? '标记未完成' : '标记完成'" @click="toggleStatus(item)">
<Icon v-if="item.status === 1" type="md-checkmark" size="14"/>
</span>
<div class="item-main" @click="toEdit(index)">
<div class="item-title">
<span class="prio-dot" :class="'prio-' + (item.priority || 2)"></span>
<span class="title-text">{{ item.title }}</span>
<span v-if="item.dueDate" class="due-chip" :class="dueClass(item)">
<Icon type="md-time" size="12"/> {{ dueText(item) }}
</span>
</div>
<div v-if="item.remark" class="item-remark">{{ item.remark }}</div>
<div class="item-meta">创建于 {{ formatCreate(item.createDate) }}</div>
</div>
<div class="item-actions">
<Button type="text" icon="ios-create-outline" size="small" @click="toEdit(index)"/>
<Poptip confirm transfer title="确定删除这条待办吗?" @on-ok="toDelete(index)">
<Button type="text" class="del-btn" icon="md-trash" size="small"/>
</Poptip>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="todo-empty">
<Icon type="md-clipboard" size="48"/>
<p v-if="statusTab === 'done'">还没有已完成的待办</p>
<p v-else-if="statusTab === 'undone'">太棒了,没有未完成的待办</p>
<p v-else>暂无待办,从上方输入框添加第一条吧</p>
</div>
<!-- 分页 -->
<div v-if="pageTotal > pageParam.pageSize" class="pagination">
<Page show-total
:total="pageTotal" :page-size="pageParam.pageSize" :current="pageParam.pageNum"
@on-page-size-change="changePageSize" @on-change="changePage"/>
</div>
</template>
<!-- 编辑弹窗 -->
<Modal v-model="modal" :title="modalTitle" width="520" class-name="todo-modal"
:mask-closable="false" @on-cancel="cancel()">
<Form :model="formItem" ref="formItem" :rules="ruleCustom" :label-width="70" label-position="left">
<FormItem label="标题" prop="title">
<Input v-model="formItem.title" maxlength="200" placeholder="待办标题"/>
</FormItem>
<FormItem label="优先级">
<RadioGroup v-model="formItem.priority" type="button" button-style="solid">
<Radio :label="1">低</Radio>
<Radio :label="2">中</Radio>
<Radio :label="3">高</Radio>
</RadioGroup>
</FormItem>
<FormItem label="状态">
<i-switch v-model="doneSwitch" size="large">
<span slot="open">已完成</span>
<span slot="close">未完成</span>
</i-switch>
</FormItem>
<FormItem label="截止日期">
<DatePicker type="date" format="yyyy-MM-dd" v-model="formItem.dueDate"
:clearable="true" class="full-input" placeholder="选填"/>
</FormItem>
<FormItem label="备注">
<Input type="textarea" :rows="3" maxlength="200" show-word-limit
v-model="formItem.remark" class="full-input" placeholder="选填:补充说明"/>
</FormItem>
</Form> </Form>
<div slot="footer"> <div slot="footer">
<Button type="text" size="large" @click="cancel">取消</Button> <Button type="text" size="large" @click="cancel">取消</Button>
<Button type="primary" size="large" <Button type="primary" size="large" :loading="submitLoading" @click="submit">保存</Button>
:loading="submitLoading"
@click="submit('formItem')">提交
</Button>
</div> </div>
</Modal> </Modal>
<!-- Modal区域 end -->
</div> </div>
</template> </template>
<script> <script>
import { getDataList, add, update, updateStatus, remove } from '@/api/todo/todo' import { getDataList, add, update, updateStatus, remove } from '@/api/todo/todo'
import { objectUtil } from '@/common/js/util'
// 飞书表格分享链接(表格视图内嵌展示);需开启「获得链接的人可编辑/阅读」
const SHEET_URL = ''
export default { export default {
name: 'todo-list', name: 'todo-list',
data () { data () {
return { return {
fixedHeader: true, // 高度自适应 // 视图模式:card 卡片 / sheet 飞书表格
autoHeight: `${window.innerHeight} ` * 0.55, // 获取当前可视高度-定义Table高度 viewMode: 'card',
search: { sheetUrl: SHEET_URL,
title: '', // 快捷添加
status: '', quickTitle: '',
priority: '' quickPriority: 2,
}, // 筛选
listColumns: [ statusTab: 'all',
{ search: { title: '', priority: null },
title: '#', // 统计(由独立请求获取总数)
type: 'index', stats: { total: 0, undone: 0, done: 0 },
width: 60, // 列表
align: 'center',
indexMethod: (row) => {
return (row._index + 1) + ((this.pageParam.pageNum - 1) * this.pageParam.pageSize)
}
},
{
title: '标题',
key: 'title',
minWidth: 200
},
{
title: '状态',
slot: 'status',
width: 110,
align: 'center'
},
{
title: '优先级',
slot: 'priority',
width: 100,
align: 'center'
},
{
title: '截止日期',
key: 'dueDate',
width: 120,
align: 'center'
},
{
title: '备注',
key: 'remark',
minWidth: 180,
ellipsis: true,
tooltip: true
},
{
title: '创建时间',
key: 'createDate',
width: 160,
align: 'center'
},
{
title: '操作区',
slot: 'action',
width: 160,
fixed: 'right',
align: 'center'
}
],
dataList: [], dataList: [],
tableLoading: false, tableLoading: false,
pageTotal: 0, // 总页数 pageTotal: 0,
pageParam: { // 调用时候的分页参数 pageParam: { pageNum: 1, pageSize: 20 },
pageNum: 1, // 弹窗
pageSize: 10
},
modal: false, modal: false,
modalTitle: '', modalTitle: '编辑待办',
addModal: false, // 添加
editModal: false, // 编辑
submitLoading: false, submitLoading: false,
doneSwitch: false,
formItem: { formItem: {
id: '', id: '', title: '', status: 0, priority: 2, dueDate: '', remark: ''
title: '',
status: 0,
priority: 2,
dueDate: '',
remark: ''
}, },
ruleCustom: { ruleCustom: {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }] title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
} }
} }
}, },
computed: {
todayText () {
const d = new Date()
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 · 星期${week}`
}
},
methods: { methods: {
// ===== 数据加载 =====
getDataList () { getDataList () {
let temp = JSON.parse(JSON.stringify(this.pageParam)) const data = {
let data = objectUtil.trimJoin(temp, this.search) pageNum: this.pageParam.pageNum,
pageSize: this.pageParam.pageSize
}
if (this.search.title) data.title = this.search.title.trim()
if (this.search.priority) data.priority = this.search.priority
if (this.statusTab === 'undone') data.status = 0
if (this.statusTab === 'done') data.status = 1
this.tableLoading = true this.tableLoading = true
getDataList(data).then(res => { getDataList(data).then(res => {
let page = res.data.data const page = res.data.data
this.pageTotal = page.total this.pageTotal = page.total
this.dataList = page.records this.dataList = page.records
// 延迟300毫秒,给它更流畅的感觉 this.tableLoading = false
setTimeout(() => { }).catch(() => { this.tableLoading = false })
this.tableLoading = false },
}, 300) // 统计卡片:分别请求三种状态的总数(pageSize=1 只取 total)
loadStats () {
const count = (extra) => getDataList(Object.assign({ pageNum: 1, pageSize: 1 }, extra)).then(res => res.data.data.total)
Promise.all([count({}), count({ status: 0 }), count({ status: 1 })]).then(([total, undone, done]) => {
this.stats = { total, undone, done }
}).catch(() => {})
},
reloadAll () {
this.loadStats()
this.getDataList()
},
// ===== 筛选交互 =====
setTab (tab) {
this.statusTab = tab
this.pageParam.pageNum = 1
this.getDataList()
},
onTabChange () {
this.pageParam.pageNum = 1
this.getDataList()
},
onSearch () {
this.pageParam.pageNum = 1
this.getDataList()
},
// ===== 快捷添加 =====
quickAdd () {
const title = (this.quickTitle || '').trim()
if (!title) {
this.$Message.warning('先输入待办内容再添加')
return
}
add({ title, priority: this.quickPriority }).then(() => {
this.quickTitle = ''
this.$Message.success('已添加')
this.pageParam.pageNum = 1
this.reloadAll()
}) })
}, },
toAdd () { // ===== 勾选完成 =====
this.modalTitle = '添加' toggleStatus (row) {
this.modal = true const status = row.status === 1 ? 0 : 1
this.addModal = true updateStatus({ id: row.id, status }).then(() => {
this.editModal = false this.$Message.success(status === 1 ? '已完成' : '已标记为未完成')
this.$nextTick(() => { this.reloadAll()
if (this.$refs.formItem) { }).catch(() => this.getDataList())
this.$refs.formItem.resetFields()
}
})
}, },
// ===== 编辑弹窗 =====
toEdit (index) { toEdit (index) {
this.modalTitle = '编辑' const temp = this.dataList[index]
let temp = this.dataList[index] this.modalTitle = '编辑待办'
this.addModal = false
this.editModal = true
this.modal = true this.modal = true
this.$nextTick(() => { this.$nextTick(() => {
// 清除上次校验状态,避免编辑回显报红 if (this.$refs.formItem) this.$refs.formItem.resetFields()
if (this.$refs.formItem) { this.formItem = {
this.$refs.formItem.resetFields() id: temp.id,
title: temp.title,
status: temp.status,
priority: temp.priority || 2,
dueDate: temp.dueDate ? new Date(String(temp.dueDate).replace(/-/g, '/')) : '',
remark: temp.remark || ''
} }
this.formItem.id = temp.id this.doneSwitch = temp.status === 1
this.formItem.title = temp.title
this.formItem.status = temp.status
this.formItem.priority = temp.priority
this.formItem.dueDate = temp.dueDate ? new Date(String(temp.dueDate).replace(/-/g, '/')) : ''
this.formItem.remark = temp.remark || ''
}) })
}, },
submit (formItem) { submit () {
// modal 提交 this.$refs.formItem.validate((valid) => {
this.$refs[formItem].validate((valid) => { if (!valid) return
if (valid) { this.submitLoading = true
let data = Object.assign({}, this.formItem) const data = Object.assign({}, this.formItem)
// DatePicker 绑定的是 Date 对象,后端按 yyyy-MM-dd 字符串接收 data.dueDate = this.formatDate(this.formItem.dueDate)
data.dueDate = this.formatDate(this.formItem.dueDate) data.status = this.doneSwitch ? 1 : 0
if (this.addModal) { // 添加 update(data).then(() => {
add(data).then(res => { this.$Message.success('保存成功')
this.$Message.success('添加成功!')
this.refresh()
this.getDataList()
})
} else { // 编辑
update(data).then(res => {
this.$Message.success('编辑成功!')
this.refresh()
this.getDataList()
})
}
this.cancel() this.cancel()
} this.reloadAll()
}).finally(() => { this.submitLoading = false })
}) })
}, },
cancel () { cancel () {
// Modal cancel
this.modal = false this.modal = false
this.refresh() if (this.$refs.formItem) this.$refs.formItem.resetFields()
}, },
toDelete (index) { toDelete (index) {
let temp = this.dataList[index] remove({ id: this.dataList[index].id }).then(() => {
let data = { this.$Message.success('删除成功')
id: temp.id // 删除后若当前页空了则回退一页
} if (this.dataList.length === 1 && this.pageParam.pageNum > 1) {
remove(data).then(res => { this.pageParam.pageNum -= 1
this.refresh() }
this.getDataList() this.reloadAll()
this.$Message.success('删除成功!')
}) })
}, },
updateStatus (row, value) { // ===== 分页 =====
let status = value === true || value === 1 || value === '1' ? 1 : 0
let data = {
id: row.id,
status: status
}
updateStatus(data).then(res => {
this.$Message.success(status === 1 ? '已完成!' : '已标记为未完成!')
this.getDataList()
}).catch(() => {
this.getDataList()
})
},
refresh () {
this.pageParam = {
pageNum: 1,
pageSize: 10
}
this.search = {
title: '',
status: '',
priority: ''
}
this.formItem = {
id: '',
title: '',
status: 0,
priority: 2,
dueDate: '',
remark: ''
}
},
changePage (index) { changePage (index) {
// 需要显示开始数据的index,(因为数据是从0开始的,页码是从1开始的,需要-1)
this.pageParam.pageNum = index this.pageParam.pageNum = index
this.getDataList() this.getDataList()
}, },
changePageSize (page) { changePageSize (size) {
// 切换分页条数
this.pageParam.pageNum = 1 this.pageParam.pageNum = 1
this.pageParam.pageSize = page this.pageParam.pageSize = size
this.getDataList() this.getDataList()
}, },
/** 日期格式化为 yyyy-MM-dd,空值返回空串 **/ // ===== 展示辅助 =====
formatDate (date) { formatDate (date) {
if (!date) { if (!date) return ''
return '' const d = date instanceof Date ? date : new Date(String(date).replace(/-/g, '/'))
} if (isNaN(d.getTime())) return ''
let d = date instanceof Date ? date : new Date(String(date).replace(/-/g, '/')) const m = d.getMonth() + 1
if (isNaN(d.getTime())) { const day = d.getDate()
return '' return `${d.getFullYear()}-${m < 10 ? '0' + m : m}-${day < 10 ? '0' + day : day}`
}
let month = d.getMonth() + 1
let day = d.getDate()
return `${d.getFullYear()}-${month < 10 ? '0' + month : month}-${day < 10 ? '0' + day : day}`
}, },
priorityName (priority) { formatCreate (val) {
let map = { 1: '低', 2: '中', 3: '高' } if (!val) return ''
return map[priority] || '中' return String(val).substring(0, 10)
}, },
priorityColor (priority) { dueDateObj (item) {
let map = { 1: 'default', 2: 'blue', 3: 'red' } if (!item.dueDate) return null
return map[priority] || 'blue' const d = new Date(String(item.dueDate).replace(/-/g, '/'))
d.setHours(0, 0, 0, 0)
return d
},
// 截止日展示:已逾期 / 今天 / 明天 / 具体日期
dueText (item) {
const d = this.dueDateObj(item)
if (!d) return ''
const today = new Date()
today.setHours(0, 0, 0, 0)
const diff = Math.round((d - today) / 86400000)
if (item.status === 0 && diff < 0) return `已逾期${-diff}天`
if (diff === 0) return '今天截止'
if (diff === 1) return '明天截止'
return this.formatDate(d)
},
dueClass (item) {
const d = this.dueDateObj(item)
if (!d || item.status === 1) return ''
const today = new Date()
today.setHours(0, 0, 0, 0)
const diff = Math.round((d - today) / 86400000)
if (diff < 0) return 'overdue'
if (diff === 0) return 'today'
return ''
} }
}, },
created () { created () {
this.getDataList() this.reloadAll()
} }
} }
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.form-input { @primary: #2d8cf0;
width: 100% @text-main: #17233d;
@text-sub: #808695;
@radius: 10px;
.todo-page {
padding: 16px;
max-width: 960px;
margin: 0 auto;
}
/* ===== 顶部 ===== */
.todo-header {
margin-bottom: 16px;
}
.todo-heading {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
}
.todo-title {
font-size: 24px;
font-weight: 600;
color: @text-main;
margin: 0;
}
.todo-date {
font-size: 13px;
color: @text-sub;
}
.stat-cards {
display: flex;
gap: 12px;
}
.stat-card {
flex: 1;
background: #fff;
border: 1px solid #e8eaec;
border-radius: @radius;
padding: 14px 16px;
cursor: pointer;
transition: all .2s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, .08);
transform: translateY(-1px);
}
&.active {
border-color: @primary;
box-shadow: 0 0 0 2px fade(@primary, 15%);
}
&.warn .stat-num { color: #ff9900; }
&.ok .stat-num { color: #19be6b; }
}
.stat-num {
font-size: 26px;
font-weight: 700;
color: @text-main;
line-height: 1.2;
}
.stat-label {
font-size: 12px;
color: @text-sub;
margin-top: 2px;
}
/* ===== 快捷添加 ===== */
.quick-add {
display: flex;
gap: 10px;
background: #fff;
border: 1px solid #e8eaec;
border-radius: @radius;
padding: 12px;
margin-bottom: 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
}
.quick-input { flex: 1; }
.quick-priority { width: 110px; flex-shrink: 0; }
.quick-btn { flex-shrink: 0; }
/* ===== 筛选栏 ===== */
.filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.filter-right {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-priority { width: 90px; }
.filter-search { width: 200px; }
.view-switch { margin-right: 8px; }
.sheet-hint { font-size: 13px; color: @text-sub; }
/* ===== 飞书表格视图 ===== */
.sheet-view {
background: #fff;
border: 1px solid #e8eaec;
border-radius: @radius;
overflow: hidden;
}
.sheet-frame {
width: 100%;
height: calc(100vh - 320px);
min-height: 480px;
border: none;
display: block;
}
.sheet-empty {
text-align: center;
padding: 72px 16px;
color: #c5c8ce;
p { margin-top: 12px; font-size: 14px; }
.sheet-tip { font-size: 12px; color: #b0b4bc; }
}
/* ===== 任务列表 ===== */
.todo-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.todo-item {
display: flex;
align-items: flex-start;
gap: 12px;
background: #fff;
border: 1px solid #e8eaec;
border-radius: @radius;
padding: 14px 16px;
transition: all .2s;
&:hover {
border-color: #dcdee2;
box-shadow: 0 2px 8px rgba(0, 0, 0, .06);
.item-actions { opacity: 1; }
}
&.is-done {
background: #fafafa;
.title-text {
color: @text-sub;
text-decoration: line-through;
}
.item-remark, .item-meta { opacity: .7; }
}
}
.check-circle {
width: 22px;
height: 22px;
flex-shrink: 0;
margin-top: 1px;
border: 2px solid #c5c8ce;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
cursor: pointer;
transition: all .15s;
&:hover { border-color: @primary; }
.is-done & {
background: #19be6b;
border-color: #19be6b;
}
}
.item-main {
flex: 1;
min-width: 0;
cursor: pointer;
}
.item-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.prio-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.prio-1 { background: #c5c8ce; }
&.prio-2 { background: #2d8cf0; }
&.prio-3 { background: #ed4014; }
}
.title-text {
font-size: 15px;
font-weight: 500;
color: @text-main;
word-break: break-all;
}
.due-chip {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 12px;
color: @text-sub;
background: #f5f7f9;
border-radius: 4px;
padding: 1px 7px;
white-space: nowrap;
&.today {
color: #ff9900;
background: fade(#ff9900, 12%);
}
&.overdue {
color: #ed4014;
background: fade(#ed4014, 10%);
font-weight: 500;
}
}
.item-remark {
font-size: 13px;
color: @text-sub;
margin-top: 6px;
word-break: break-all;
white-space: pre-wrap;
}
.item-meta {
font-size: 12px;
color: #c5c8ce;
margin-top: 6px;
}
.item-actions {
flex-shrink: 0;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity .2s;
.del-btn { color: #ed4014; }
}
/* ===== 空状态 / 分页 / 加载 ===== */
.todo-empty {
text-align: center;
padding: 56px 0;
color: #c5c8ce;
p { margin-top: 12px; font-size: 14px; }
}
.todo-spin { display: block; margin: 60px auto; }
.pagination {
margin-top: 16px;
text-align: center;
}
.full-input { width: 100%; }
/* ===== 手机端适配(≤768px) ===== */
@media (max-width: 768px) {
.todo-page { padding: 10px; }
.todo-title { font-size: 20px; }
.stat-card { padding: 10px 12px; }
.stat-num { font-size: 20px; }
.quick-add {
flex-wrap: wrap;
.quick-input { flex: 1 1 100%; }
.quick-priority { flex: 1; width: auto; }
.quick-btn { flex: 1; }
}
.filter-bar { flex-direction: column; align-items: stretch; }
.filter-right { width: 100%; }
.filter-priority { width: 100px; }
.filter-search { flex: 1; width: auto; }
.todo-item { padding: 12px; }
.item-actions { opacity: 1; } // 手机端常显操作按钮
.sheet-frame { height: 60vh; min-height: 360px; }
} }
</style> </style>