2 Commits
Author SHA1 Message Date
jif2.zhang 32f404ac90 fix(任务): 修复列表与看板拖拽「能触发但放不下」—— uuid 主键被 Number() 成 NaN
Docker Build and Push / build-image (push) Successful in 1m34s
0.2.1 切本地库后 Task.id / BoardColumn.id 为 uuid 字符串, 拖拽落点却用
Number(dataset.xxxId) 取值得到 NaN:
  - 看板 hitColumn 返回 NaN -> NaN 为 falsy, onDrop 永不触发; 目标列也不高亮
  - 列表 colId/taskId 为 NaN -> 组内命中与换列 findIndex 恒失败, 松手无操作
(打卡侧仍走 REST 数字 id, 故未受影响)

- 新增 src/pages/task/ids.js: idStr / sameId / datasetId, 统一按字符串比对
- useColumnDrag / useRowDrag: 落点、组内命中、换列、提交判定改用 sameId
- ProjectView: moveToColumn / reorderTask / changeStatus 改用 sameId,
  落点为空直接忽略, 不把坏 id 写进本地库
- api/task.js: update 拦截 "NaN"/"undefined" 列 id, 报「看板列无效」

验证: headless Edge + CDP 派发真实指针事件驱动两个 hook (uuid 数据),
修复前 4 条落点断言全挂, 修复后全部通过。
2026-10-27 10:06:37 +08:00
jif2.zhang 06e9a3e694 fix(任务): 编号改为 #序号展示、详情页完整编号可复制、修复详情改列报「看板列不存在」
- 列表/看板任务编号徽标改为 `#序号`(如 #18), 移除不再需要的 codePrefix 透传
- 详情页标题下新增完整任务编号胶囊 (项目编号-序号, 如 ADMIN-18), 点按复制:
  Clipboard API 优先, 非安全上下文/旧 WebView 回退 execCommand, 并提示已复制
- 修复详情页「看板列」切换报错: 0.2.1 本地库列 id 为 uuid 字符串,
  ActionSheet 的 key 恒为字符串, 旧代码 Number(key) 得到 NaN ->
  update 校验失败抛「看板列不存在」。改为字符串比对 (currentCol/勾选标记同步)
- 详情页保存后先就地替换该行再回源刷新
2026-10-27 10:06:12 +08:00
12 changed files with 177 additions and 43 deletions
+8 -1
View File
@@ -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
+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>
)}
+4 -2
View File
@@ -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()
}
+9 -8
View File
@@ -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,
+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}
+20 -11
View File
@@ -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}
/>
)}
+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',
})
+26
View File
@@ -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
}
+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 {