feat(任务/打卡): 已归档页面按原型重做; 打卡日历完成度改 4 档配色
Docker Build and Push / build-image (push) Successful in 1m27s
Docker Build and Push / build-image (push) Successful in 1m27s
一、已归档任务管理: 按评审通过的原型落地 (docs/V0.2/任务模块/已归档任务管理-原型.html)
- 去掉「任务类型」过滤: 类型是弱维度, 归档场景几乎不按它检索, 反而占掉一整行;
类型标签仍在行内展示。
- 关键词输入框前加常驻标签「标题」, 框内占位改为「输入关键词」。
- 归档时间新增快捷项: 全部 / 本周 / 本月 / 本季度 / 自定义;
选中后下方灰字回显实际区间 (本周=周一起算, 区间截至今天, 不跨未来);
「自定义」才展开两个日期选择器。
- 补上「筛选」按钮 (带生效条件数徽标) 与「重置」: 条件先编辑, 点按钮统一提交。
原实现改任一条件就即时重查, 连点会连发请求, 输关键词时列表会闪。
- 列表上方显示已应用条件标签 (可单独 ✕ 撤销) + 命中条数。
- 列表行重排: 标题独占一行, 元信息收成一行 (#编号 · 看板列 · 归档于日期),
「恢复 / 删除」由右侧竖排改到底部右侧横排, 降低行高。
- 空状态区分「这个项目还没有归档任务」(提示去 ⋯ 菜单归档) 与
「没有符合条件的归档任务」(建议放宽条件)。
- 修正日期边界: archivedList 的 from/to 原来直接拼 'T00:00:00Z',
本地选的日期会偏移 8 小时, 当天凌晨的归档被漏掉; 改为按本地时区换算成 UTC 瞬时。
二、打卡日历完成度配色: 由 3 档改 4 档
小半 (ratio < 0.5) -> 红 #ff3141
大半 (0.5 <= r < 1) -> 橘 #ff8f1f
完成 (r = 1) -> 蓝 #1677ff
超额 -> 绿 #00b578
注意服务端统计时把 actual 封顶在 expected (`Math.min(cnt, exp)`),
所以 ratio 永远 <= 1,**超额只能靠 status === 'over' 判断**,不能看比例;
DayRing 内新增 tierOf() 统一分级,图例同步改为四档。
验证 (真实 Windows 外壳 + CDP):
- 打卡页: 图例显示「小半/大半/完成/超额」, 计算样式分别为
rgb(255,49,65) / rgb(255,143,31) / rgb(22,119,255) / rgb(0,181,120);
当月实际渲染出 low=5 / high=1 / over=1。
- 已归档页: 筛选行标签为 标题/看板列/归档时间; 时间选项五项齐全;
按钮为 重置/筛选; 点「本周」回显 2026-10-26 ~ 2026-10-28, 点「筛选」后
徽标显示 1、条件标签可撤销、命中条数正确。
- 归档 → 已归档列表可见 → 恢复 全链路走通 (含上次修好的 TDZ 问题),
服务端确认 archived 由 t 变回 f。
- 控制台无异常。
This commit is contained in:
@@ -360,7 +360,7 @@
|
||||
| --- | --- |
|
||||
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 |
|
||||
| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 |
|
||||
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(关键词/类型/列/归档时段)+ 分页「加载更多」+ 行内「恢复 / 删除」 |
|
||||
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(**关键词 / 看板列 / 归档时段**;时段含「本周 / 本月 / 本季度」快捷项与自定义区间;条件编辑后点「筛选」统一提交,另有「重置」;**不提供类型过滤**)+ 已应用条件标签(可单独撤销)与命中条数 + 分页「加载更多」+ 行内「恢复 / 删除」 |
|
||||
| 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 |
|
||||
|
||||
### 15.5 边界
|
||||
|
||||
+14
-4
@@ -763,13 +763,23 @@ export const taskApi = {
|
||||
where.push('columnId = ?')
|
||||
args.push(String(p.columnId))
|
||||
}
|
||||
// 日期边界: 传 'YYYY-MM-DD' 时按**本地时区**的那一天换算成 UTC 瞬时再比较。
|
||||
// (原来直接拼 'T00:00:00Z', 本地选的日期会偏移 8 小时, 导致当天凌晨的归档被漏掉)
|
||||
const dayToIso = (v, endOfDay) => {
|
||||
const s = String(v)
|
||||
if (s.includes('T')) return s // 已带时间的 ISO, 原样使用
|
||||
const d = new Date(`${s}T00:00:00`) // 本地 00:00
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
if (endOfDay) d.setHours(23, 59, 59, 999)
|
||||
return d.toISOString()
|
||||
}
|
||||
if (p.from) {
|
||||
where.push('archivedAt >= ?')
|
||||
args.push(String(p.from) + 'T00:00:00+08:00'.replace('+08:00', 'Z'))
|
||||
const iso = dayToIso(p.from, false)
|
||||
if (iso) { where.push('archivedAt >= ?'); args.push(iso) }
|
||||
}
|
||||
if (p.to) {
|
||||
where.push('archivedAt <= ?')
|
||||
args.push(String(p.to) + 'T23:59:59.999Z')
|
||||
const iso = dayToIso(p.to, true)
|
||||
if (iso) { where.push('archivedAt <= ?'); args.push(iso) }
|
||||
}
|
||||
if (p.cursor) {
|
||||
const [ts, cid] = String(p.cursor).split('|')
|
||||
|
||||
@@ -2,16 +2,31 @@
|
||||
//
|
||||
// 为什么是环而不是圆点:
|
||||
// 旧实现是 4px 小圆点, 只按状态上色 —— 3/5 和 5/5 都是同一个绿点,
|
||||
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色仍表状态:
|
||||
// 0 < actual < expected -> 弧长按比例 (部分完成 / 半填充)
|
||||
// actual >= expected -> 整圈 (全部完成 / 实心)
|
||||
// actual = 0 且 expected > 0 -> 只有底环 (未开始 / 空心)
|
||||
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色表状态:
|
||||
// 0 < actual < expected -> 弧长按比例 (部分完成)
|
||||
// actual >= expected -> 整圈 (全部完成)
|
||||
// actual = 0 且 expected > 0 -> 只有底环 (未开始)
|
||||
// expected = 0 由父组件判断, 不渲染本组件 (当天没有应打卡项)
|
||||
//
|
||||
// 颜色不写在这里: 由 CSS 按 `.mv-day` 作用域上色 (各视图调色板可能不同)。
|
||||
// 颜色分级 (4 档, 由 CSS 按 `.mv-day` 作用域上色, 各视图可各自调色板):
|
||||
// 小半 (ratio < 0.5) -> 红
|
||||
// 大半 (0.5 <= r < 1) -> 橘
|
||||
// 完成 (r = 1) -> 蓝
|
||||
// 超额 -> 绿
|
||||
// 注意: 服务端统计时把 actual 封顶在 expected (`Math.min(cnt, exp)`),
|
||||
// 所以 ratio 永远 <= 1, **超额只能靠 status === 'over' 判断**, 不能看比例。
|
||||
const DEFAULT_SIZE = 30
|
||||
const DEFAULT_STROKE = 2.5
|
||||
|
||||
/** 4 档分级: 小半 / 大半 / 完成 / 超额 */
|
||||
function tierOf(status, p) {
|
||||
if (status === 'over') return 'over'
|
||||
if (status === 'full') return 'full'
|
||||
if (p <= 0) return 'empty'
|
||||
if (p >= 1) return 'full'
|
||||
return p < 0.5 ? 'low' : 'high'
|
||||
}
|
||||
|
||||
export default function DayRing({
|
||||
status = 'partial',
|
||||
ratio = 0,
|
||||
@@ -19,6 +34,7 @@ export default function DayRing({
|
||||
stroke = DEFAULT_STROKE,
|
||||
}) {
|
||||
const p = Math.max(0, Math.min(1, Number(ratio) || 0))
|
||||
const tier = tierOf(status, p)
|
||||
const r = (size - stroke) / 2
|
||||
const c = 2 * Math.PI * r
|
||||
const half = size / 2
|
||||
@@ -37,7 +53,7 @@ export default function DayRing({
|
||||
/>
|
||||
{p > 0 && (
|
||||
<circle
|
||||
className={'day-ring-bar ' + status}
|
||||
className={'day-ring-bar ' + tier}
|
||||
cx={half} cy={half} r={r}
|
||||
fill="none" strokeWidth={stroke}
|
||||
strokeDasharray={c}
|
||||
|
||||
@@ -292,9 +292,10 @@ export default function MergedView({ date, onDateChange }) {
|
||||
{cal && (
|
||||
<div className="mv-rate">
|
||||
<span>本月完成率 <b>{Math.round((cal.rate || 0) * 100)}%</b></span>
|
||||
{/* 图例: 弧长=完成比例, 颜色=状态 */}
|
||||
{/* 图例: 弧长=完成比例, 颜色=4 档 (小半/大半/完成/超额) */}
|
||||
<span className="mv-legend">
|
||||
<i className="partial" />部分
|
||||
<i className="low" />小半
|
||||
<i className="high" />大半
|
||||
<i className="full" />完成
|
||||
<i className="over" />超额
|
||||
</span>
|
||||
|
||||
@@ -1,29 +1,67 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Selector, Toast } from 'antd-mobile'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Toast } from 'antd-mobile'
|
||||
import { taskApi } from '../../api/task.js'
|
||||
import { kindColor } from './kind.js'
|
||||
|
||||
// 已归档任务查询 (T-ARC-04/05/06)
|
||||
// 过滤: 关键词(标题) / 任务类型 / 看板列 / 归档时间范围
|
||||
// 过滤: 关键词(标题) / 看板列 / 归档时间 (含 本周/本月/本季度 快捷项)
|
||||
// 交互: 条件先编辑 -> 点「筛选」才提交
|
||||
// (原实现改任一条件就即时重查, 连点会连发请求, 输关键词时还会闪)
|
||||
// 游标分页: 「加载更多」追加; 行内 恢复 / 彻底删除
|
||||
// 不再提供「任务类型」过滤: 类型是弱维度, 归档场景几乎不按类型检索
|
||||
const LIMIT = 20
|
||||
|
||||
const EMPTY = { keyword: '', columnId: '', time: 'all', from: '', to: '' }
|
||||
|
||||
const TIME_OPTIONS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'week', label: '本周' },
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'quarter', label: '本季度' },
|
||||
{ key: 'custom', label: '自定义' },
|
||||
]
|
||||
const TIME_LABEL = { week: '本周', month: '本月', quarter: '本季度' }
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const fmtDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
|
||||
/**
|
||||
* 快捷时间 -> [from, to]。本地日期,区间截止到今天(不跨到未来)。
|
||||
* 纯函数,便于单测。
|
||||
*/
|
||||
export function presetRange(key, now = new Date()) {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
if (key === 'week') {
|
||||
const dow = (today.getDay() + 6) % 7 // 周一为 0
|
||||
const mon = new Date(today)
|
||||
mon.setDate(today.getDate() - dow)
|
||||
return [fmtDate(mon), fmtDate(today)]
|
||||
}
|
||||
if (key === 'month') {
|
||||
return [fmtDate(new Date(today.getFullYear(), today.getMonth(), 1)), fmtDate(today)]
|
||||
}
|
||||
if (key === 'quarter') {
|
||||
const qm = Math.floor(today.getMonth() / 3) * 3 // 当季首月
|
||||
return [fmtDate(new Date(today.getFullYear(), qm, 1)), fmtDate(today)]
|
||||
}
|
||||
return ['', '']
|
||||
}
|
||||
|
||||
export default function ArchivedTasks({ project, visible, onClose, onChanged }) {
|
||||
const [items, setItems] = useState(null)
|
||||
const [nextCursor, setNextCursor] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState({ keyword: '', kind: '', columnId: '', from: '', to: '' })
|
||||
const [draft, setDraft] = useState(EMPTY) // 表单里正在编辑的条件
|
||||
const [applied, setApplied] = useState(EMPTY) // 已提交、真正参与查询的条件
|
||||
const [fromVisible, setFromVisible] = useState(false)
|
||||
const [toVisible, setToVisible] = useState(false)
|
||||
const cursorRef = useRef('')
|
||||
|
||||
const columns = project?.columns || []
|
||||
const kinds = project?.taskKinds || []
|
||||
|
||||
const buildParams = (f, cursor) => {
|
||||
const p = { limit: LIMIT }
|
||||
if (f.keyword.trim()) p.keyword = f.keyword.trim()
|
||||
if (f.kind) p.kind = f.kind
|
||||
if (f.keyword) p.keyword = f.keyword
|
||||
if (f.columnId) p.columnId = f.columnId
|
||||
if (f.from) p.from = f.from
|
||||
if (f.to) p.to = f.to
|
||||
@@ -35,7 +73,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
setLoading(true)
|
||||
try {
|
||||
const cursor = reset ? '' : cursorRef.current
|
||||
const r = await taskApi.archivedList(project.id, buildParams(filters, cursor))
|
||||
const r = await taskApi.archivedList(project.id, buildParams(applied, cursor))
|
||||
setItems((prev) => (reset || !prev ? r.items : [...prev, ...r.items]))
|
||||
cursorRef.current = r.nextCursor || ''
|
||||
setNextCursor(r.nextCursor || null)
|
||||
@@ -45,7 +83,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project.id, filters])
|
||||
}, [project.id, applied])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
@@ -54,7 +92,51 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
load(true)
|
||||
}, [visible, load])
|
||||
|
||||
const setF = (patch) => setFilters((p) => ({ ...p, ...patch }))
|
||||
const setD = (patch) => setDraft((p) => ({ ...p, ...patch }))
|
||||
const colName = (id) => columns.find((c) => String(c.id) === String(id))?.name || ''
|
||||
const fmt = (iso) => (iso ? String(iso).slice(0, 10) : '')
|
||||
|
||||
const activeCount =
|
||||
(applied.keyword ? 1 : 0) + (applied.columnId ? 1 : 0) + (applied.time !== 'all' ? 1 : 0)
|
||||
|
||||
const appliedTags = useMemo(() => {
|
||||
const tags = []
|
||||
if (applied.keyword) tags.push({ key: 'keyword', text: `标题: ${applied.keyword}` })
|
||||
if (applied.columnId) tags.push({ key: 'columnId', text: `看板列: ${colName(applied.columnId)}` })
|
||||
if (applied.time === 'custom') {
|
||||
if (applied.from || applied.to) {
|
||||
tags.push({ key: 'time', text: `${applied.from || '…'} ~ ${applied.to || '…'}` })
|
||||
}
|
||||
} else if (applied.time !== 'all') {
|
||||
tags.push({ key: 'time', text: TIME_LABEL[applied.time] })
|
||||
}
|
||||
return tags
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [applied, columns])
|
||||
|
||||
function submit() {
|
||||
const [f, t] = draft.time === 'custom' ? [draft.from, draft.to] : presetRange(draft.time)
|
||||
setApplied({ ...draft, keyword: draft.keyword.trim(), from: f, to: t })
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setDraft(EMPTY)
|
||||
setApplied(EMPTY)
|
||||
}
|
||||
|
||||
// 去掉某一条生效条件 (即时生效, 不必再点按钮)
|
||||
function dropTag(key) {
|
||||
const nextA = { ...applied }
|
||||
const nextD = { ...draft }
|
||||
if (key === 'keyword') { nextA.keyword = ''; nextD.keyword = '' }
|
||||
if (key === 'columnId') { nextA.columnId = ''; nextD.columnId = '' }
|
||||
if (key === 'time') {
|
||||
nextA.time = 'all'; nextA.from = ''; nextA.to = ''
|
||||
nextD.time = 'all'; nextD.from = ''; nextD.to = ''
|
||||
}
|
||||
setApplied(nextA)
|
||||
setDraft(nextD)
|
||||
}
|
||||
|
||||
async function doRestore(item) {
|
||||
try {
|
||||
@@ -62,7 +144,6 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
Toast.show({ content: '已恢复' })
|
||||
setItems((prev) => (prev || []).filter((x) => x.id !== item.id))
|
||||
onChanged?.()
|
||||
load(true)
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message || '恢复失败' })
|
||||
}
|
||||
@@ -71,7 +152,9 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
async function doRemove(item) {
|
||||
const subs = item.children?.length || 0
|
||||
const ok = await Dialog.confirm({
|
||||
content: subs ? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。` : `彻底删除「${item.title}」?不可恢复。`,
|
||||
content: subs
|
||||
? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。`
|
||||
: `彻底删除「${item.title}」?不可恢复。`,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -85,8 +168,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (iso) => (iso ? iso.slice(0, 10) : '')
|
||||
const colName = (id) => columns.find((c) => c.id === id)?.name || ''
|
||||
const draftRange = draft.time === 'custom' ? ['', ''] : presetRange(draft.time)
|
||||
|
||||
return (
|
||||
<Popup
|
||||
@@ -99,43 +181,77 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
<div className="navbar-sub">
|
||||
<span className="back" onClick={onClose}>‹</span>
|
||||
<span className="title">已归档任务</span>
|
||||
{project?.name ? <span className="arc-proj">{project.name}</span> : null}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* 筛选卡 */}
|
||||
<div className="arc-fcard">
|
||||
<div className="arc-frow">
|
||||
<span className="lbl">标题</span>
|
||||
<span className="ctl">
|
||||
<span className="arc-kw">
|
||||
<Input
|
||||
placeholder="搜索任务标题"
|
||||
value={filters.keyword}
|
||||
placeholder="输入关键词"
|
||||
value={draft.keyword}
|
||||
clearable
|
||||
onEnter={() => load(true)}
|
||||
onChange={(v) => setF({ keyword: v })}
|
||||
onBlur={() => load(true)}
|
||||
onChange={(v) => setD({ keyword: v })}
|
||||
onEnter={submit}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Selector
|
||||
options={[{ label: '全部类型', value: '' }, ...kinds.map((k) => ({ label: k, value: k }))]}
|
||||
value={[filters.kind]}
|
||||
onSelect={(_, ext) => { setF({ kind: ext.items[0].value }); setTimeout(() => load(true), 0) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Selector
|
||||
options={[{ label: '全部看板列', value: '' }, ...columns.map((c) => ({ label: c.name, value: String(c.id) }))]}
|
||||
value={[filters.columnId]}
|
||||
onSelect={(_, ext) => { setF({ columnId: ext.items[0].value }); setTimeout(() => load(true), 0) }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, color: '#999' }}>
|
||||
<span>归档</span>
|
||||
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setFromVisible(true)}>
|
||||
{filters.from || '开始日期'}
|
||||
</span>
|
||||
<span>~</span>
|
||||
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setToVisible(true)}>
|
||||
{filters.to || '结束日期'}
|
||||
</span>
|
||||
{(filters.from || filters.to) && (
|
||||
<span className="link" onClick={() => { setF({ from: '', to: '' }); setTimeout(() => load(true), 0) }}>清除</span>
|
||||
</div>
|
||||
|
||||
<div className="arc-frow">
|
||||
<span className="lbl">看板列</span>
|
||||
<span className="ctl">
|
||||
<span className="arc-chips">
|
||||
<span
|
||||
className={'arc-chip' + (draft.columnId === '' ? ' on' : '')}
|
||||
onClick={() => setD({ columnId: '' })}
|
||||
>全部</span>
|
||||
{columns.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className={'arc-chip' + (String(draft.columnId) === String(c.id) ? ' on' : '')}
|
||||
onClick={() => setD({ columnId: String(c.id) })}
|
||||
>{c.name}</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="arc-frow">
|
||||
<span className="lbl">归档时间</span>
|
||||
<span className="ctl">
|
||||
<span className="arc-chips">
|
||||
{TIME_OPTIONS.map((o) => (
|
||||
<span
|
||||
key={o.key}
|
||||
className={'arc-chip' + (draft.time === o.key ? ' on' : '')}
|
||||
onClick={() => setD({ time: o.key, ...(o.key === 'custom' ? {} : { from: '', to: '' }) })}
|
||||
>{o.label}</span>
|
||||
))}
|
||||
</span>
|
||||
{draft.time === 'custom' && (
|
||||
<span className="arc-range">
|
||||
<span className={'dp' + (draft.from ? ' on' : '')} onClick={() => setFromVisible(true)}>
|
||||
{draft.from || '开始日期'}
|
||||
</span>
|
||||
<span className="sep">~</span>
|
||||
<span className={'dp' + (draft.to ? ' on' : '')} onClick={() => setToVisible(true)}>
|
||||
{draft.to || '结束日期'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{draftRange[0] && <div className="arc-hint">{draftRange[0]} ~ {draftRange[1]}</div>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="arc-actions">
|
||||
<button type="button" className="arc-btn ghost" onClick={reset}>重置</button>
|
||||
<button type="button" className="arc-btn primary" onClick={submit}>
|
||||
筛选{activeCount ? <span className="badge">{activeCount}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,61 +259,72 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
visible={fromVisible}
|
||||
onClose={() => setFromVisible(false)}
|
||||
precision="day"
|
||||
onConfirm={(d) => {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
setF({ from: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` })
|
||||
setFromVisible(false)
|
||||
setTimeout(() => load(true), 0)
|
||||
}}
|
||||
onConfirm={(d) => { setD({ from: fmtDate(d) }); setFromVisible(false) }}
|
||||
/>
|
||||
<DatePicker
|
||||
visible={toVisible}
|
||||
onClose={() => setToVisible(false)}
|
||||
precision="day"
|
||||
onConfirm={(d) => {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
setF({ to: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` })
|
||||
setToVisible(false)
|
||||
setTimeout(() => load(true), 0)
|
||||
}}
|
||||
onConfirm={(d) => { setD({ to: fmtDate(d) }); setToVisible(false) }}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '0 14px 24px', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{items === null && loading && <div style={{ textAlign: 'center', padding: 30 }}><DotLoading /> 加载中...</div>}
|
||||
{items !== null && items.length === 0 && !loading && (
|
||||
<div style={{ textAlign: 'center', color: '#b8bcc4', padding: 40 }}>没有符合条件的归档任务</div>
|
||||
{/* 已应用条件 (可单独撤销) */}
|
||||
{appliedTags.length > 0 && (
|
||||
<div className="arc-applied">
|
||||
{appliedTags.map((t) => (
|
||||
<span key={t.key} className="tag">
|
||||
{t.text}
|
||||
<span className="x" onClick={() => dropTag(t.key)}>✕</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="cnt">共 {items?.length || 0} 条</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="arc-list">
|
||||
{items === null && loading && (
|
||||
<div className="arc-empty"><DotLoading /> 加载中…</div>
|
||||
)}
|
||||
|
||||
{items !== null && items.length === 0 && !loading && (
|
||||
appliedTags.length === 0 ? (
|
||||
<div className="arc-empty">
|
||||
<div className="ico">📭</div>
|
||||
这个项目还没有归档任务
|
||||
<div className="sub">任务完成后可在它的 ⋯ 菜单里归档</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="arc-empty">
|
||||
<div className="ico">🗂</div>
|
||||
没有符合条件的归档任务
|
||||
<div className="sub">试试放宽时间范围或清空关键词</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{(items || []).map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
background: '#fff', borderRadius: 9, padding: '10px 12px', display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.04)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, wordBreak: 'break-word' }}>{t.title}</div>
|
||||
<div style={{ fontSize: 10.5, color: '#999', marginTop: 4, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{t.kind && <span className="kind-tag" style={{ background: kindColor(t.kind) }}>{t.kind}</span>}
|
||||
<span>{colName(t.columnId)}</span>
|
||||
<div key={t.id} className="arc-item">
|
||||
<div className="top">
|
||||
<div className="ttl">{t.title}</div>
|
||||
{t.number != null && <span className="num-tag">#{t.number}</span>}
|
||||
</div>
|
||||
<div className="meta">
|
||||
{t.kind && (
|
||||
<span className="kind-tag" style={{ background: kindColor(t.kind) }}>{t.kind}</span>
|
||||
)}
|
||||
{colName(t.columnId) && <span className="col">{colName(t.columnId)}</span>}
|
||||
<span className="dot" />
|
||||
<span>归档于 {fmt(t.archivedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, flexShrink: 0 }}>
|
||||
<Button size="mini" color="primary" fill="outline" onClick={() => doRestore(t)}>恢复</Button>
|
||||
<Button size="mini" color="danger" fill="none" onClick={() => doRemove(t)}>删除</Button>
|
||||
<div className="acts">
|
||||
<span className="mini" onClick={() => doRestore(t)}>恢复</span>
|
||||
<span className="mini danger" onClick={() => doRemove(t)}>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{nextCursor && (
|
||||
<Button
|
||||
block
|
||||
fill="outline"
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={() => load(false)}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
<Button block fill="outline" size="small" loading={loading} onClick={() => load(false)}>
|
||||
加载更多
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+94
-6
@@ -883,9 +883,11 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
position: relative; width: 26px; height: 26px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mv-day .day-ring-bar.partial { stroke: #ff8f1f; }
|
||||
.mv-day .day-ring-bar.full { stroke: #00b578; }
|
||||
.mv-day .day-ring-bar.over { stroke: #ff6b6b; }
|
||||
/* 环颜色 4 档: 小半(红) / 大半(橘) / 完成(蓝) / 超额(绿) */
|
||||
.mv-day .day-ring-bar.low { stroke: #ff3141; }
|
||||
.mv-day .day-ring-bar.high { stroke: #ff8f1f; }
|
||||
.mv-day .day-ring-bar.full { stroke: #1677ff; }
|
||||
.mv-day .day-ring-bar.over { stroke: #00b578; }
|
||||
/* 选中日: 蓝底之上改用白色环, 弧长仍表达完成度 */
|
||||
.mv-day.sel .day-ring-bg { stroke: rgba(255, 255, 255, .38); }
|
||||
.mv-day.sel .day-ring-bar { stroke: #fff; }
|
||||
@@ -895,9 +897,10 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
.mv-legend { display: inline-flex; align-items: center; gap: 4px; color: #b0b6bf; }
|
||||
.mv-legend i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-left: 5px; }
|
||||
.mv-legend i:first-child { margin-left: 0; }
|
||||
.mv-legend i.partial { background: #ff8f1f; }
|
||||
.mv-legend i.full { background: #00b578; }
|
||||
.mv-legend i.over { background: #ff6b6b; }
|
||||
.mv-legend i.low { background: #ff3141; }
|
||||
.mv-legend i.high { background: #ff8f1f; }
|
||||
.mv-legend i.full { background: #1677ff; }
|
||||
.mv-legend i.over { background: #00b578; }
|
||||
|
||||
/* ---- 当日栏 ---- */
|
||||
.mv-daybar { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; font-size: 13px; font-weight: 600; }
|
||||
@@ -1312,3 +1315,88 @@ textarea.inp { resize: vertical; }
|
||||
border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.drag-pill.cur { background: #c0c7d0; }
|
||||
|
||||
/* ---- 已归档任务 (T-ARC-04/05/06) ----
|
||||
结构: 筛选卡(标题/看板列/归档时间 + 重置/筛选) -> 已应用条件 -> 列表
|
||||
交互: 条件先编辑, 点「筛选」统一提交; 生效条件可在列表上方单独撤销 */
|
||||
.arc-proj {
|
||||
margin-left: auto; font-size: 11px; color: #999; background: #f5f6f8;
|
||||
border-radius: 999px; padding: 2px 9px; max-width: 130px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.arc-fcard {
|
||||
background: #fff; margin: 10px 12px 0; border-radius: 12px; padding: 4px 14px 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
|
||||
}
|
||||
.arc-frow { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #f4f5f7; }
|
||||
.arc-frow:last-of-type { border-bottom: 0; }
|
||||
.arc-frow > .lbl { width: 56px; flex-shrink: 0; font-size: 12.5px; font-weight: 600; color: var(--color-text); }
|
||||
.arc-frow > .ctl { flex: 1; min-width: 0; }
|
||||
|
||||
/* 关键词输入框: 前面有常驻标签「标题」, 框内是占位提示 */
|
||||
.arc-kw {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
background: #f7f8fa; border: 1px solid #eceef1; border-radius: 8px; padding: 6px 9px;
|
||||
}
|
||||
.arc-kw:focus-within { border-color: var(--color-primary); background: #fff; }
|
||||
.arc-kw .adm-input { flex: 1; min-width: 0; border: 0; background: transparent; padding: 0; --font-size: 13px; }
|
||||
.arc-kw .adm-input-element { font-size: 13px; }
|
||||
|
||||
.arc-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.arc-chip {
|
||||
font-size: 12px; padding: 4px 10px; border-radius: 999px;
|
||||
border: 1px solid #e6e8eb; background: #fafbfc; color: var(--color-muted);
|
||||
cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.arc-chip.on { background: #eaf3ff; border-color: var(--color-primary); color: var(--color-primary); font-weight: 600; }
|
||||
|
||||
.arc-range { display: flex; align-items: center; gap: 7px; margin-top: 8px; }
|
||||
.arc-range .dp {
|
||||
font-size: 12px; padding: 4px 10px; border: 1px dashed #d9dee5; border-radius: 7px;
|
||||
color: #999; cursor: pointer; background: #fff;
|
||||
}
|
||||
.arc-range .dp.on { color: var(--color-text); border-style: solid; border-color: #d9dee5; }
|
||||
.arc-range .sep { color: #c2c6cc; font-size: 12px; }
|
||||
.arc-hint { font-size: 11px; color: #9aa4b2; margin-top: 6px; }
|
||||
|
||||
.arc-actions { display: flex; gap: 9px; padding-top: 12px; }
|
||||
.arc-btn { height: 36px; border-radius: 9px; border: 0; font-size: 13.5px; font-weight: 600; cursor: pointer; }
|
||||
.arc-btn.ghost { flex: 0 0 84px; background: #f4f5f7; color: var(--color-muted); }
|
||||
.arc-btn.primary { flex: 1; background: var(--color-primary); color: #fff; }
|
||||
.arc-btn .badge {
|
||||
display: inline-block; min-width: 16px; height: 16px; line-height: 16px; border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .28); font-size: 10.5px; margin-left: 5px; padding: 0 4px;
|
||||
}
|
||||
|
||||
.arc-applied { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 11px 14px 3px; }
|
||||
.arc-applied .tag {
|
||||
font-size: 11.5px; background: #eaf3ff; color: var(--color-primary);
|
||||
border-radius: 6px; padding: 2px 7px; display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.arc-applied .tag .x { cursor: pointer; opacity: .6; }
|
||||
.arc-applied .cnt { margin-left: auto; font-size: 11.5px; color: #999; }
|
||||
|
||||
.arc-list { padding: 8px 12px 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.arc-item { background: #fff; border-radius: 10px; padding: 11px 12px; box-shadow: 0 1px 2px rgba(0, 0, 0, .04); }
|
||||
.arc-item .top { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.arc-item .ttl { flex: 1; min-width: 0; font-size: 13.5px; font-weight: 600; line-height: 1.42; word-break: break-word; }
|
||||
.arc-item .meta {
|
||||
display: flex; gap: 7px; align-items: center; flex-wrap: wrap;
|
||||
margin-top: 7px; font-size: 10.5px; color: #999;
|
||||
}
|
||||
.arc-item .meta .col { color: var(--color-muted); }
|
||||
.arc-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: #d5d9e0; }
|
||||
.arc-item .acts {
|
||||
display: flex; gap: 7px; justify-content: flex-end;
|
||||
margin-top: 9px; padding-top: 9px; border-top: 1px dashed #f0f1f3;
|
||||
}
|
||||
.arc-item .acts .mini {
|
||||
font-size: 11.5px; padding: 3px 12px; border-radius: 999px;
|
||||
border: 1px solid var(--color-primary); color: var(--color-primary);
|
||||
background: #fff; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.arc-item .acts .mini.danger { border-color: #ffd4d7; color: var(--color-danger); }
|
||||
|
||||
.arc-empty { text-align: center; padding: 52px 20px; color: #b8bcc4; font-size: 13px; }
|
||||
.arc-empty .ico { font-size: 34px; opacity: .35; margin-bottom: 10px; }
|
||||
.arc-empty .sub { font-size: 11.5px; color: #c2c6cc; margin-top: 6px; }
|
||||
|
||||
Reference in New Issue
Block a user