Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32f404ac90 | ||
|
|
06e9a3e694 |
+8
-1
@@ -536,10 +536,17 @@ export const taskApi = {
|
||||
}
|
||||
|
||||
// 列 / 状态联动裁决 (与 server/task-domain/resolveStatus.js 同口径)
|
||||
// 本地库主键是 uuid 字符串: 若调用方把 id 过了一道 Number() 会变成 NaN / "NaN",
|
||||
// 这里直接拦下并给出可定位的错误, 而不是让后续比对静默失败
|
||||
let status = task.status
|
||||
let columnId = String(task.columnId)
|
||||
const patch = {}
|
||||
if (body?.columnId !== undefined) patch.columnId = String(body.columnId)
|
||||
if (body?.columnId !== undefined) {
|
||||
const cid = body.columnId === null ? '' : String(body.columnId)
|
||||
if (!cid) throw new Error('看板列不能为空')
|
||||
if (cid === 'NaN' || cid === 'undefined') throw new Error('看板列无效(编号已损坏)')
|
||||
patch.columnId = cid
|
||||
}
|
||||
if (body?.status !== undefined) {
|
||||
if (!['todo', 'doing', 'done'].includes(body.status)) throw new Error('任务状态取值非法')
|
||||
patch.status = body.status
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { datasetId, sameId } from '../../pages/task/ids.js'
|
||||
|
||||
// 看板卡片列拖动 (重点设计 §7.1)
|
||||
// 方案要点:
|
||||
@@ -60,10 +61,11 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
||||
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
|
||||
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
|
||||
|
||||
// 命中列 id: 本地库列 id 是 uuid 字符串, 不能 Number() (会变 NaN -> 落不下去)
|
||||
const hitColumn = (x, y) => {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
const colEl = el?.closest?.('[data-column-id]')
|
||||
return colEl ? Number(colEl.dataset.columnId) : null
|
||||
return colEl ? datasetId(colEl.dataset.columnId) : null
|
||||
}
|
||||
|
||||
// 边缘自动滚动 (±36px 触发, 每帧 10px)
|
||||
@@ -105,7 +107,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
||||
if (s.active) {
|
||||
lastEnd.current = Date.now()
|
||||
const col = hitColumn(ev.clientX, ev.clientY)
|
||||
if (col && s.task && col !== s.task.columnId) onDropRef.current?.(s.task, col)
|
||||
if (col && s.task && !sameId(col, s.task.columnId)) onDropRef.current?.(s.task, col)
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { datasetId, sameId } from '../../pages/task/ids.js'
|
||||
|
||||
// 列表视图任务行拖动: 组间换列 + 组内排序
|
||||
// 触发: 行长按 400ms, 或按住行首 ⠿ 把手 (touch-action:none + touchstart
|
||||
@@ -78,15 +79,15 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
|
||||
moveGhost(ev.clientX, ev.clientY)
|
||||
|
||||
// 组内落点: 命中本组 ([data-column-id]=当前列) 的某条顶层行
|
||||
// 本地库的列/任务 id 均为 uuid 字符串 -> 一律按字符串比对, 不要 Number()
|
||||
const hitEl = document.elementFromPoint(ev.clientX, ev.clientY)
|
||||
const rowEl = hitEl?.closest?.('[data-task-id]')
|
||||
const colEl = hitEl?.closest?.('[data-column-id]')
|
||||
const colId = colEl ? Number(colEl.dataset.columnId) : null
|
||||
const curCol = orderedRef.current[s.toIdx]?.id
|
||||
const colId = colEl ? datasetId(colEl.dataset.columnId) : null
|
||||
|
||||
if (rowEl && colId === s.task.columnId) {
|
||||
const id = Number(rowEl.dataset.taskId)
|
||||
if (id !== s.task.id) {
|
||||
if (rowEl && sameId(colId, s.task.columnId)) {
|
||||
const id = datasetId(rowEl.dataset.taskId)
|
||||
if (id && !sameId(id, s.task.id)) {
|
||||
const r = rowEl.getBoundingClientRect()
|
||||
const hit = { id, after: ev.clientY > r.top + r.height / 2 }
|
||||
if (!s.over || s.over.id !== hit.id || s.over.after !== hit.after) {
|
||||
@@ -100,7 +101,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
|
||||
|
||||
// 换列: 手指划到哪个分组就是目标列, 划出所有分组时保持上一目标
|
||||
if (colId != null) {
|
||||
const to = orderedRef.current.findIndex((c) => c.id === colId)
|
||||
const to = orderedRef.current.findIndex((c) => sameId(c.id, colId))
|
||||
if (to >= 0 && to !== s.toIdx) {
|
||||
s.toIdx = to
|
||||
setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to })
|
||||
@@ -116,7 +117,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
|
||||
onReorderRef.current?.(s.task, s.over.id, s.over.after)
|
||||
} else {
|
||||
const target = orderedRef.current[s.toIdx]
|
||||
if (target && target.id !== s.task.columnId) onDropRef.current?.(s.task, target.id)
|
||||
if (target && !sameId(target.id, s.task.columnId)) onDropRef.current?.(s.task, target.id)
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
@@ -152,7 +153,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
|
||||
}
|
||||
|
||||
const startDrag = (ev, task, instant) => {
|
||||
const fromIdx = orderedRef.current.findIndex((c) => c.id === task.columnId)
|
||||
const fromIdx = orderedRef.current.findIndex((c) => sameId(c.id, task.columnId))
|
||||
if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动
|
||||
st.current = {
|
||||
...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx,
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
|
||||
import { projectApi, taskApi } from '../../api/task.js'
|
||||
import { filterVisible, msToNextBoundary } from './date.js'
|
||||
import { sameId } from './ids.js'
|
||||
import { getViewMode, setViewMode } from './prefs.js'
|
||||
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||
import ListView from './ListView.jsx'
|
||||
@@ -71,6 +72,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
|
||||
@@ -93,10 +101,12 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
|
||||
/** 拖拽换列 (T-BD-04/07) */
|
||||
const moveToColumn = async (task, columnId) => {
|
||||
const col = columns.find((c) => c.id === columnId)
|
||||
// columnId 来自拖动落点 (uuid 字符串); 空值直接忽略, 不要把坏 id 写进本地库
|
||||
if (columnId === null || columnId === undefined || columnId === '') return
|
||||
const col = columns.find((c) => sameId(c.id, columnId))
|
||||
try {
|
||||
await updateTask(task.id, { columnId })
|
||||
const target = columns.find((c) => c.id === columnId)
|
||||
const target = columns.find((c) => sameId(c.id, columnId))
|
||||
if (target?.boundStatus && target.boundStatus !== task.status) {
|
||||
Toast.show({ content: `已移入 ${col?.name},状态改为「${target.boundStatus === 'done' ? '完成' : target.boundStatus === 'doing' ? '执行中' : '未开始'}」` })
|
||||
} else {
|
||||
@@ -109,11 +119,11 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
const reorderTask = async (task, targetId, after) => {
|
||||
const snapshot = tasks
|
||||
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const groups = orderedCols.map((c) => (tasks || []).filter((t) => t.columnId === c.id)).filter((l) => l.length)
|
||||
const g = groups.find((l) => l.some((t) => t.id === task.id))
|
||||
const groups = orderedCols.map((c) => (tasks || []).filter((t) => sameId(t.columnId, c.id))).filter((l) => l.length)
|
||||
const g = groups.find((l) => l.some((t) => sameId(t.id, task.id)))
|
||||
if (!g) return
|
||||
const rest = g.filter((t) => t.id !== task.id)
|
||||
const ti = rest.findIndex((t) => t.id === targetId)
|
||||
const rest = g.filter((t) => !sameId(t.id, task.id))
|
||||
const ti = rest.findIndex((t) => sameId(t.id, targetId))
|
||||
if (ti < 0) return
|
||||
rest.splice(after ? ti + 1 : ti, 0, task)
|
||||
const flat = groups.map((l) => (l === g ? rest : l)).flat()
|
||||
@@ -136,9 +146,9 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
const changeStatus = async (task, status) => {
|
||||
try {
|
||||
const fresh = await updateTask(task.id, { status })
|
||||
const moved = fresh && fresh.columnId !== task.columnId
|
||||
const moved = fresh && !sameId(fresh.columnId, task.columnId)
|
||||
if (moved) {
|
||||
const col = columns.find((c) => c.id === fresh.columnId)
|
||||
const col = columns.find((c) => sameId(c.id, fresh.columnId))
|
||||
Toast.show({ content: `已移至 ${col?.name || '目标列'}` })
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -276,7 +286,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 +296,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 +309,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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 任务模块: 主键比对工具
|
||||
//
|
||||
// 为什么要单独一个模块:
|
||||
// 0.2.1 把任务模块切到 PowerSync 本地库后, 主键不再是数据库自增整数,
|
||||
// 而是 **uuid 字符串** (`Task.id` / `BoardColumn.id` / `Project.id` 均为 uuid)。
|
||||
// 旧代码里大量 `Number(el.dataset.xxxId)` / `Number(key)` 的写法会得到 NaN,
|
||||
// 而 NaN 与任何值都不相等 (且为 falsy), 表现为「拖拽能激活但松手落不下去」
|
||||
// 「换列提示看板列不存在」这类静默失败。
|
||||
// 凡是从 DOM dataset / ActionSheet key / 本地存储读回来的 id, 一律走这里比对。
|
||||
|
||||
/** 统一成字符串再比较; 空值 (null/undefined/'') 返回 '' */
|
||||
export function idStr(v) {
|
||||
return v === null || v === undefined ? '' : String(v)
|
||||
}
|
||||
|
||||
/** 两个 id 是否相同 (uuid 字符串 / 数字都适用) */
|
||||
export function sameId(a, b) {
|
||||
const x = idStr(a)
|
||||
return x !== '' && x === idStr(b)
|
||||
}
|
||||
|
||||
/** DOM dataset 里的 id: 空/缺失返回 null, 便于 `if (id)` 判空 */
|
||||
export function datasetId(v) {
|
||||
const s = idStr(v)
|
||||
return s === '' || s === 'undefined' || s === 'null' ? null : s
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user