feat(任务/打卡): 已归档页面按原型重做; 打卡日历完成度改 4 档配色
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:
jif2.zhang
2026-10-28 10:19:17 +08:00
parent 77e057c580
commit c5fc328f2b
6 changed files with 349 additions and 107 deletions
@@ -360,7 +360,7 @@
| --- | --- | | --- | --- |
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 | | 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 |
| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 | | 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 |
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(关键词/类型/列/归档时段)+ 分页「加载更多」+ 行内「恢复 / 删除」 | | 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(**关键词 / 看板列 / 归档时段**;时段含「本周 / 本月 / 本季度」快捷项与自定义区间;条件编辑后点「筛选」统一提交,另有「重置」;**不提供类型过滤**)+ 已应用条件标签(可单独撤销)与命中条数 + 分页「加载更多」+ 行内「恢复 / 删除」 |
| 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 | | 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 |
### 15.5 边界 ### 15.5 边界
+14 -4
View File
@@ -763,13 +763,23 @@ export const taskApi = {
where.push('columnId = ?') where.push('columnId = ?')
args.push(String(p.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) { if (p.from) {
where.push('archivedAt >= ?') const iso = dayToIso(p.from, false)
args.push(String(p.from) + 'T00:00:00+08:00'.replace('+08:00', 'Z')) if (iso) { where.push('archivedAt >= ?'); args.push(iso) }
} }
if (p.to) { if (p.to) {
where.push('archivedAt <= ?') const iso = dayToIso(p.to, true)
args.push(String(p.to) + 'T23:59:59.999Z') if (iso) { where.push('archivedAt <= ?'); args.push(iso) }
} }
if (p.cursor) { if (p.cursor) {
const [ts, cid] = String(p.cursor).split('|') const [ts, cid] = String(p.cursor).split('|')
+22 -6
View File
@@ -2,16 +2,31 @@
// //
// 为什么是环而不是圆点: // 为什么是环而不是圆点:
// 旧实现是 4px 小圆点, 只按状态上色 —— 3/5 和 5/5 都是同一个绿点, // 旧实现是 4px 小圆点, 只按状态上色 —— 3/5 和 5/5 都是同一个绿点,
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色仍表状态: // 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色表状态:
// 0 < actual < expected -> 弧长按比例 (部分完成 / 半填充) // 0 < actual < expected -> 弧长按比例 (部分完成)
// actual >= expected -> 整圈 (全部完成 / 实心) // actual >= expected -> 整圈 (全部完成)
// actual = 0 且 expected > 0 -> 只有底环 (未开始 / 空心) // actual = 0 且 expected > 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_SIZE = 30
const DEFAULT_STROKE = 2.5 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({ export default function DayRing({
status = 'partial', status = 'partial',
ratio = 0, ratio = 0,
@@ -19,6 +34,7 @@ export default function DayRing({
stroke = DEFAULT_STROKE, stroke = DEFAULT_STROKE,
}) { }) {
const p = Math.max(0, Math.min(1, Number(ratio) || 0)) const p = Math.max(0, Math.min(1, Number(ratio) || 0))
const tier = tierOf(status, p)
const r = (size - stroke) / 2 const r = (size - stroke) / 2
const c = 2 * Math.PI * r const c = 2 * Math.PI * r
const half = size / 2 const half = size / 2
@@ -37,7 +53,7 @@ export default function DayRing({
/> />
{p > 0 && ( {p > 0 && (
<circle <circle
className={'day-ring-bar ' + status} className={'day-ring-bar ' + tier}
cx={half} cy={half} r={r} cx={half} cy={half} r={r}
fill="none" strokeWidth={stroke} fill="none" strokeWidth={stroke}
strokeDasharray={c} strokeDasharray={c}
+3 -2
View File
@@ -292,9 +292,10 @@ export default function MergedView({ date, onDateChange }) {
{cal && ( {cal && (
<div className="mv-rate"> <div className="mv-rate">
<span>本月完成率 <b>{Math.round((cal.rate || 0) * 100)}%</b></span> <span>本月完成率 <b>{Math.round((cal.rate || 0) * 100)}%</b></span>
{/* 图例: 弧长=完成比例, 颜色=状态 */} {/* 图例: 弧长=完成比例, 颜色=4 档 (小半/大半/完成/超额) */}
<span className="mv-legend"> <span className="mv-legend">
<i className="partial" />部分 <i className="low" />小半
<i className="high" />大半
<i className="full" />完成 <i className="full" />完成
<i className="over" />超额 <i className="over" />超额
</span> </span>
+210 -83
View File
@@ -1,29 +1,67 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Selector, Toast } from 'antd-mobile' import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Toast } from 'antd-mobile'
import { taskApi } from '../../api/task.js' import { taskApi } from '../../api/task.js'
import { kindColor } from './kind.js' import { kindColor } from './kind.js'
// 已归档任务查询 (T-ARC-04/05/06) // 已归档任务查询 (T-ARC-04/05/06)
// 过滤: 关键词(标题) / 任务类型 / 看板列 / 归档时间范围 // 过滤: 关键词(标题) / 看板列 / 归档时间 (含 本周/本月/本季度 快捷项)
// 交互: 条件先编辑 -> 点「筛选」才提交
// (原实现改任一条件就即时重查, 连点会连发请求, 输关键词时还会闪)
// 游标分页: 「加载更多」追加; 行内 恢复 / 彻底删除 // 游标分页: 「加载更多」追加; 行内 恢复 / 彻底删除
// 不再提供「任务类型」过滤: 类型是弱维度, 归档场景几乎不按类型检索
const LIMIT = 20 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 }) { export default function ArchivedTasks({ project, visible, onClose, onChanged }) {
const [items, setItems] = useState(null) const [items, setItems] = useState(null)
const [nextCursor, setNextCursor] = useState(null) const [nextCursor, setNextCursor] = useState(null)
const [loading, setLoading] = useState(false) 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 [fromVisible, setFromVisible] = useState(false)
const [toVisible, setToVisible] = useState(false) const [toVisible, setToVisible] = useState(false)
const cursorRef = useRef('') const cursorRef = useRef('')
const columns = project?.columns || [] const columns = project?.columns || []
const kinds = project?.taskKinds || []
const buildParams = (f, cursor) => { const buildParams = (f, cursor) => {
const p = { limit: LIMIT } const p = { limit: LIMIT }
if (f.keyword.trim()) p.keyword = f.keyword.trim() if (f.keyword) p.keyword = f.keyword
if (f.kind) p.kind = f.kind
if (f.columnId) p.columnId = f.columnId if (f.columnId) p.columnId = f.columnId
if (f.from) p.from = f.from if (f.from) p.from = f.from
if (f.to) p.to = f.to if (f.to) p.to = f.to
@@ -35,7 +73,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
setLoading(true) setLoading(true)
try { try {
const cursor = reset ? '' : cursorRef.current 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])) setItems((prev) => (reset || !prev ? r.items : [...prev, ...r.items]))
cursorRef.current = r.nextCursor || '' cursorRef.current = r.nextCursor || ''
setNextCursor(r.nextCursor || null) setNextCursor(r.nextCursor || null)
@@ -45,7 +83,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
setLoading(false) setLoading(false)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.id, filters]) }, [project.id, applied])
useEffect(() => { useEffect(() => {
if (!visible) return if (!visible) return
@@ -54,7 +92,51 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
load(true) load(true)
}, [visible, load]) }, [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) { async function doRestore(item) {
try { try {
@@ -62,7 +144,6 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
Toast.show({ content: '已恢复' }) Toast.show({ content: '已恢复' })
setItems((prev) => (prev || []).filter((x) => x.id !== item.id)) setItems((prev) => (prev || []).filter((x) => x.id !== item.id))
onChanged?.() onChanged?.()
load(true)
} catch (e) { } catch (e) {
Toast.show({ content: e.message || '恢复失败' }) Toast.show({ content: e.message || '恢复失败' })
} }
@@ -71,7 +152,9 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
async function doRemove(item) { async function doRemove(item) {
const subs = item.children?.length || 0 const subs = item.children?.length || 0
const ok = await Dialog.confirm({ const ok = await Dialog.confirm({
content: subs ? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。` : `彻底删除「${item.title}」?不可恢复。`, content: subs
? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。`
: `彻底删除「${item.title}」?不可恢复。`,
confirmText: '删除', confirmText: '删除',
}) })
if (!ok) return if (!ok) return
@@ -85,8 +168,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
} }
} }
const fmt = (iso) => (iso ? iso.slice(0, 10) : '') const draftRange = draft.time === 'custom' ? ['', ''] : presetRange(draft.time)
const colName = (id) => columns.find((c) => c.id === id)?.name || ''
return ( return (
<Popup <Popup
@@ -99,43 +181,77 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
<div className="navbar-sub"> <div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span> <span className="back" onClick={onClose}>‹</span>
<span className="title">已归档任务</span> <span className="title">已归档任务</span>
{project?.name ? <span className="arc-proj">{project.name}</span> : null}
</div> </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 <Input
placeholder="搜索任务标题" placeholder="输入关键词"
value={filters.keyword} value={draft.keyword}
clearable clearable
onEnter={() => load(true)} onChange={(v) => setD({ keyword: v })}
onChange={(v) => setF({ keyword: v })} onEnter={submit}
onBlur={() => load(true)}
/> />
<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>
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setToVisible(true)}>
{filters.to || '结束日期'}
</span> </span>
{(filters.from || filters.to) && ( </div>
<span className="link" onClick={() => { setF({ from: '', to: '' }); setTimeout(() => load(true), 0) }}>清除</span>
<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>
</div> </div>
@@ -143,61 +259,72 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
visible={fromVisible} visible={fromVisible}
onClose={() => setFromVisible(false)} onClose={() => setFromVisible(false)}
precision="day" precision="day"
onConfirm={(d) => { onConfirm={(d) => { setD({ from: fmtDate(d) }); setFromVisible(false) }}
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)
}}
/> />
<DatePicker <DatePicker
visible={toVisible} visible={toVisible}
onClose={() => setToVisible(false)} onClose={() => setToVisible(false)}
precision="day" precision="day"
onConfirm={(d) => { onConfirm={(d) => { setD({ to: fmtDate(d) }); setToVisible(false) }}
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)
}}
/> />
<div style={{ padding: '0 14px 24px', display: 'flex', flexDirection: 'column', gap: 7 }}> {/* 已应用条件 (可单独撤销) */}
{items === null && loading && <div style={{ textAlign: 'center', padding: 30 }}><DotLoading /> 加载中...</div>} {appliedTags.length > 0 && (
{items !== null && items.length === 0 && !loading && ( <div className="arc-applied">
<div style={{ textAlign: 'center', color: '#b8bcc4', padding: 40 }}>没有符合条件的归档任务</div> {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) => ( {(items || []).map((t) => (
<div <div key={t.id} className="arc-item">
key={t.id} <div className="top">
style={{ <div className="ttl">{t.title}</div>
background: '#fff', borderRadius: 9, padding: '10px 12px', display: 'flex', gap: 10, alignItems: 'flex-start', {t.number != null && <span className="num-tag">#{t.number}</span>}
boxShadow: '0 1px 2px rgba(0,0,0,.04)', </div>
}} <div className="meta">
> {t.kind && (
<div style={{ flex: 1, minWidth: 0 }}> <span className="kind-tag" style={{ background: kindColor(t.kind) }}>{t.kind}</span>
<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' }}> {colName(t.columnId) && <span className="col">{colName(t.columnId)}</span>}
{t.kind && <span className="kind-tag" style={{ background: kindColor(t.kind) }}>{t.kind}</span>} <span className="dot" />
<span>{colName(t.columnId)}</span>
<span>归档于 {fmt(t.archivedAt)}</span> <span>归档于 {fmt(t.archivedAt)}</span>
</div> </div>
</div> <div className="acts">
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, flexShrink: 0 }}> <span className="mini" onClick={() => doRestore(t)}>恢复</span>
<Button size="mini" color="primary" fill="outline" onClick={() => doRestore(t)}>恢复</Button> <span className="mini danger" onClick={() => doRemove(t)}>删除</span>
<Button size="mini" color="danger" fill="none" onClick={() => doRemove(t)}>删除</Button>
</div> </div>
</div> </div>
))} ))}
{nextCursor && ( {nextCursor && (
<Button <Button block fill="outline" size="small" loading={loading} onClick={() => load(false)}>
block
fill="outline"
size="small"
loading={loading}
onClick={() => load(false)}
style={{ marginTop: 4 }}
>
加载更多 加载更多
</Button> </Button>
)} )}
+94 -6
View File
@@ -883,9 +883,11 @@ body.me-safe-top .app-safe-top { height: 0; }
position: relative; width: 26px; height: 26px; position: relative; width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
.mv-day .day-ring-bar.partial { stroke: #ff8f1f; } /* 环颜色 4 档: 小半(红) / 大半(橘) / 完成(蓝) / 超额(绿) */
.mv-day .day-ring-bar.full { stroke: #00b578; } .mv-day .day-ring-bar.low { stroke: #ff3141; }
.mv-day .day-ring-bar.over { stroke: #ff6b6b; } .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-bg { stroke: rgba(255, 255, 255, .38); }
.mv-day.sel .day-ring-bar { stroke: #fff; } .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 { 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 { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-left: 5px; }
.mv-legend i:first-child { margin-left: 0; } .mv-legend i:first-child { margin-left: 0; }
.mv-legend i.partial { background: #ff8f1f; } .mv-legend i.low { background: #ff3141; }
.mv-legend i.full { background: #00b578; } .mv-legend i.high { background: #ff8f1f; }
.mv-legend i.over { background: #ff6b6b; } .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; } .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; border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;
} }
.drag-pill.cur { background: #c0c7d0; } .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; }