- {codePrefix && task.number != null && (
-
{codePrefix}-{task.number}
- )}
+ {shortId &&
{shortId}}
{task.markDate && (
{task.markDate.slice(5, 10).replace('-', '/')}
)}
diff --git a/src/components/task/TaskRow.jsx b/src/components/task/TaskRow.jsx
index 9bddf99..53880db 100644
--- a/src/components/task/TaskRow.jsx
+++ b/src/components/task/TaskRow.jsx
@@ -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 (
<>
{task.title}
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
- {codePrefix && task.number != null && (
-
{codePrefix}-{task.number}
- )}
+ {shortId &&
{shortId}}
{task.markDate && (
{task.markDate.slice(5, 10).replace('-', '/')}
)}
diff --git a/src/pages/task/BoardView.jsx b/src/pages/task/BoardView.jsx
index 8cc932d..a434c7b 100644
--- a/src/pages/task/BoardView.jsx
+++ b/src/pages/task/BoardView.jsx
@@ -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({
{ if (!drag.dragTask && !drag.justEnded()) onOpen?.(t) }}
diff --git a/src/pages/task/ListView.jsx b/src/pages/task/ListView.jsx
index c93600c..326abff 100644
--- a/src/pages/task/ListView.jsx
+++ b/src/pages/task/ListView.jsx
@@ -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({
{ if (!drag.justEnded()) onOpen(t) }}
onMore={onMore}
diff --git a/src/pages/task/ProjectView.jsx b/src/pages/task/ProjectView.jsx
index 6f13b6b..c9c995d 100644
--- a/src/pages/task/ProjectView.jsx
+++ b/src/pages/task/ProjectView.jsx
@@ -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
setOpenTask(t)}
onMore={openTaskMenu}
@@ -287,7 +293,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
setOpenTask(t)}
onToggleSub={(c) => toggleDone(c)}
@@ -301,12 +306,13 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
{openTask && (
setOpenTask(null)}
- onSaved={() => { setOpenTask(null); load() }}
+ onSaved={handleSaved}
onChanged={load}
/>
)}
diff --git a/src/pages/task/TaskDetail.jsx b/src/pages/task/TaskDetail.jsx
index fe2df7f..208a2ce 100644
--- a/src/pages/task/TaskDetail.jsx
+++ b/src/pages/task/TaskDetail.jsx
@@ -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
}}
>
{task.title}
+ {fullId && (
+
+
+ {fullId}
+ {copied ? '已复制' : '复制'}
+
+
+ )}
{currentCol?.name} · {STATUS_LABEL[task.status]}
diff --git a/src/pages/task/date.js b/src/pages/task/date.js
index 19c5f60..fad7b9f 100644
--- a/src/pages/task/date.js
+++ b/src/pages/task/date.js
@@ -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',
})
diff --git a/src/styles/globals.css b/src/styles/globals.css
index e2a4f12..7adf449 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -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 {