fix(任务): 编号改为 #序号展示、详情页完整编号可复制、修复详情改列报「看板列不存在」

- 列表/看板任务编号徽标改为 `#序号`(如 #18), 移除不再需要的 codePrefix 透传
- 详情页标题下新增完整任务编号胶囊 (项目编号-序号, 如 ADMIN-18), 点按复制:
  Clipboard API 优先, 非安全上下文/旧 WebView 回退 execCommand, 并提示已复制
- 修复详情页「看板列」切换报错: 0.2.1 本地库列 id 为 uuid 字符串,
  ActionSheet 的 key 恒为字符串, 旧代码 Number(key) 得到 NaN ->
  update 校验失败抛「看板列不存在」。改为字符串比对 (currentCol/勾选标记同步)
- 详情页保存后先就地替换该行再回源刷新
This commit is contained in:
jif2.zhang
2026-10-27 10:06:12 +08:00
parent 317072539d
commit 06e9a3e694
8 changed files with 119 additions and 24 deletions
+5 -4
View File
@@ -1,6 +1,7 @@
import StatusDot from './StatusDot.jsx'
import Avatar from './Avatar.jsx'
import { kindColor } from '../../pages/task/kind.js'
import { taskShortId } from '../../pages/task/date.js'
// 看板卡片 (T-UI-02): 轻量化
// - 标题 + 状态点 (T-ST-09)
@@ -9,11 +10,13 @@ import { kindColor } from '../../pages/task/kind.js'
const MAX_SUB = 3
export default function TaskCard({
task, codePrefix, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false,
task, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false,
}) {
const children = task.children || []
const shown = children.slice(0, MAX_SUB)
const rest = children.length - shown.length
// 编号只显示 `#序号`; 完整编号 (项目编号-序号) 放详情页 (见 TaskDetail)
const shortId = taskShortId(task)
return (
<div
@@ -47,9 +50,7 @@ export default function TaskCard({
</div>
<div className="bmeta">
{codePrefix && task.number != null && (
<span className="num-tag">{codePrefix}-{task.number}</span>
)}
{shortId && <span className="num-tag">{shortId}</span>}
{task.markDate && (
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
)}
+5 -4
View File
@@ -1,6 +1,7 @@
import StatusDot from './StatusDot.jsx'
import Avatar from './Avatar.jsx'
import { kindColor } from '../../pages/task/kind.js'
import { taskShortId } from '../../pages/task/date.js'
const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中' : '未开始')
@@ -10,9 +11,11 @@ const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中
// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动,
// 避免长按行后抬手时合成的 click 落在控件上误触状态切换
export default function TaskRow({
task, codePrefix, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragging = false, dragOver, indent = false, children,
task, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragging = false, dragOver, indent = false, children,
}) {
const done = task.status === 'done'
// 编号只显示 `#序号`; 完整编号 (项目编号-序号) 放详情页 (见 TaskDetail)
const shortId = indent ? null : taskShortId(task)
return (
<>
<div
@@ -52,9 +55,7 @@ export default function TaskRow({
<div className="task-title">{task.title}</div>
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
<div className="task-meta">
{codePrefix && task.number != null && (
<span className="num-tag">{codePrefix}-{task.number}</span>
)}
{shortId && <span className="num-tag">{shortId}</span>}
{task.markDate && (
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
)}
+1 -2
View File
@@ -36,7 +36,7 @@ function ScrollBody({ children }) {
// 横向滚列; 列内独立纵向滚动, 列头固定, 滚动时右缘显示滚动条
// 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」
export default function BoardView({
tasks, columns, codePrefix, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
}) {
const drag = useColumnDrag({ onDrop: onMoveColumn })
@@ -69,7 +69,6 @@ export default function BoardView({
<TaskCard
key={t.id}
task={t}
codePrefix={codePrefix}
dragging={drag.dragTask?.id === t.id}
bindDrag={drag.bind}
onClick={() => { if (!drag.dragTask && !drag.justEnded()) onOpen?.(t) }}
+1 -2
View File
@@ -7,7 +7,7 @@ import useRowDrag from '../../components/task/useRowDrag.js'
// 换列: 行/把手拖动, 跟手浮层, 手指划到哪个分组松手就落到哪 (与看板一致)
// (列表只展示有任务的列, 空列需到看板视图拖入)
export default function ListView({
tasks, columns, codePrefix, onToggle, onOpen, onMore, onMoveColumn, onReorder,
tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder,
}) {
const [collapsed, setCollapsed] = useState({})
const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder })
@@ -49,7 +49,6 @@ export default function ListView({
<div key={t.id}>
<TaskRow
task={t}
codePrefix={codePrefix}
onToggle={onToggle}
onClick={() => { if (!drag.justEnded()) onOpen(t) }}
onMore={onMore}
+9 -3
View File
@@ -71,6 +71,13 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const replaceTask = (fresh) => setTasks((prev) => (prev || []).map((t) => (t.id === fresh.id ? fresh : t)))
/** 详情页保存后: 先就地替换该行, 再回源刷新 (不回源也能立刻看到编号/列变更) */
const handleSaved = (fresh) => {
if (fresh?.id) replaceTask(fresh)
setOpenTask(null)
load()
}
/** 更新任务(列/状态/字段), 服务端裁决后以返回值为准 */
async function updateTask(id, patch) {
const snapshot = tasks
@@ -276,7 +283,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
<ListView
tasks={visibleTasks}
columns={columns}
codePrefix={project?.code}
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
onMore={openTaskMenu}
@@ -287,7 +293,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
<BoardView
tasks={visibleTasks}
columns={columns}
codePrefix={project?.code}
onMoveColumn={moveToColumn}
onOpen={(t) => setOpenTask(t)}
onToggleSub={(c) => toggleDone(c)}
@@ -301,12 +306,13 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
{openTask && (
<TaskDetail
task={openTask}
projectCode={project.code}
columns={columns}
members={project.members || []}
tasks={tasks || []}
taskKinds={project.taskKinds}
onClose={() => setOpenTask(null)}
onSaved={() => { setOpenTask(null); load() }}
onSaved={handleSaved}
onChanged={load}
/>
)}
+66 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { ActionSheet, Popup, Toast, DatePicker } from 'antd-mobile'
import { taskApi } from '../../api/task.js'
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
import { STATUS_OPTIONS, STATUS_LABEL, taskFullId } from './date.js'
import { kindColor } from './kind.js'
import { parseTimePrefix, splitTitle } from './quickAdd.js'
import UserPicker from '../../components/task/UserPicker.jsx'
@@ -9,11 +9,35 @@ import StatusDot from '../../components/task/StatusDot.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js'
// 复制文本: 优先 Clipboard API (需安全上下文), 打包端 http 明文 / 旧 WebView 回落 execCommand
async function copyText(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
} catch { /* 继续走兜底 */ }
try {
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', '')
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'
document.body.appendChild(ta)
ta.select()
ta.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
export default function TaskDetail({ task, columns, members, tasks, taskKinds, onClose, onSaved, onChanged }) {
export default function TaskDetail({ task, projectCode, columns, members, tasks, taskKinds, onClose, onSaved, onChanged }) {
const [form, setForm] = useState(() => ({
title: task.title || '',
description: task.description || '',
@@ -38,8 +62,25 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.name)
const currentCol = (columns || []).find((c) => c.id === task.columnId)
const currentCol = (columns || []).find((c) => String(c.id) === String(task.columnId))
const isSubtask = task.parentId != null
// 完整任务编号 (项目编号-序号), 点按复制; 未定号 (离线新建未上行) 不展示
const fullId = taskFullId(task, projectCode)
const [copied, setCopied] = useState(false)
useEffect(() => { setCopied(false) }, [task.id])
useEffect(() => {
if (!copied) return undefined
const t = setTimeout(() => setCopied(false), 1500)
return () => clearTimeout(t)
}, [copied])
async function copyId(e) {
e.stopPropagation()
const ok = await copyText(fullId)
setCopied(ok)
Toast.show({ content: ok ? `已复制 ${fullId}` : '复制失败,请长按手动复制' })
}
// 可作为父任务的候选: 同项目顶层任务, 排除自己与已有子任务的任务 (T-ST-01/13)
const parentCandidates = (tasks || []).filter(
(t) => t.id !== task.id && (t.children || []).length === 0,
@@ -82,23 +123,26 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
}
}
/** 换列: 服务端按目标列绑定裁决状态 (T-BD-04/07) */
/** 换列: 服务端按目标列绑定裁决状态 (T-BD-04/07)
* 本地库的列 id 为 uuid 字符串, 而 ActionSheet 的 key 会被转成字符串 ——
* 这里始终按 String() 比对, 不要 Number(): 否则结果变 NaN, 落库列被改成 "NaN" 报「看板列不存在」 */
function pickColumn() {
const current = String(task.columnId)
ActionSheet.show({
actions: (columns || []).map((c) => ({
text: c.name + (c.id === task.columnId ? ' ✓' : ''),
text: c.name + (String(c.id) === current ? ' ✓' : ''),
key: String(c.id),
})),
cancelText: '取消',
onAction: async (item) => {
const columnId = Number(item.key)
if (columnId === task.columnId) return
const columnId = String(item.key)
if (columnId === current) return
try {
const r = await taskApi.update(task.id, { columnId })
Toast.show({ content: `已移入 ${(columns || []).find((c) => c.id === columnId)?.name || '目标列'}` })
Toast.show({ content: `已移入 ${(columns || []).find((c) => String(c.id) === columnId)?.name || '目标列'}` })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
Toast.show({ content: e.message || '换列失败' })
}
},
})
@@ -161,6 +205,19 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
}}
>
<div className="sheet-title">{task.title}</div>
{fullId && (
<div className="sheet-idrow">
<span
className="task-id-chip"
role="button"
title="点击复制完整任务编号"
onClick={copyId}
>
<span className="tid">{fullId}</span>
<span className="tid-copy">{copied ? '已复制' : '复制'}</span>
</span>
</div>
)}
<div className="sheet-sub">
{currentCol?.name} · {STATUS_LABEL[task.status]}
</div>
+17
View File
@@ -16,6 +16,23 @@ export const RETENTION_OPTIONS = ['never', 'today', '3d', 'week', 'month', 'fore
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
export const STATUS_OPTIONS = ['todo', 'doing', 'done']
// 任务编号 (T-TK-编号): 服务端分配 `Project.code` + 项目内自增 `Task.number`
// - 列表 / 看板: 只显示 `#序号` (短, 不占卡片宽度)
// - 详情页: 显示完整编号 `项目编号-序号`, 点按复制
// - 离线新建、上行定号前的任务 number 为空 -> 两处都不出编号 (不做假编号)
/** 列表 / 看板的短编号: '#12'; 未定号返回 null */
export function taskShortId(task) {
const n = task?.number
return n === null || n === undefined || n === '' ? null : `#${n}`
}
/** 完整任务编号: 'IB-12'; 未定号返回 null */
export function taskFullId(task, code) {
const n = task?.number
if (n === null || n === undefined || n === '') return null
return code ? `${code}-${n}` : `#${n}`
}
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
})
+15
View File
@@ -1207,6 +1207,21 @@ body.me-safe-top .app-safe-top { height: 0; }
font-size: 11.5px; color: #999; margin-bottom: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 详情页标题下的完整任务编号 (项目编号-序号): 点按复制 */
.sheet-idrow { margin: -1px 0 6px; }
.task-id-chip {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; line-height: 1; font-weight: 600;
color: #5b6b7c; background: #eef2f6; border-radius: 999px; padding: 5px 9px;
cursor: pointer; -webkit-user-select: none; user-select: none;
font-variant-numeric: tabular-nums;
}
.task-id-chip:active { background: #e2e8ef; }
.task-id-chip .tid { letter-spacing: .3px; }
.task-id-chip .tid-copy {
color: var(--color-primary); font-weight: 500;
padding-left: 6px; border-left: 1px solid rgba(91,107,124,.22);
}
.field { margin-bottom: 13px; }
.field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
.inp {