feat(V0.2): 任务操作菜单 / 状态指示 / 移动与转子任务 / 移除扩展信息
Docker Build and Push / build-image (push) Successful in 4m13s

1. 任务 ⋯ 菜单 (列表行与看板卡片): 编辑 / 拷贝到其他项目 / 删除
   (antd-mobile ActionSheet, 带任务名标题; 拷贝抽出为 CopyTaskPopup 复用)
2. 点击任务非按钮区域 = 打开编辑页 (原本即如此, ⋯ 不再误触为编辑)
3. 修复状态选择器堆成三行: 此前 .seg 只有 .pv-head 作用域样式,
   详情弹层裸用导致纵向堆叠; 新增通用 .seg 分段样式, 当前项高亮点按即切
4. 移除任务的扩展信息 UI (T-TK-04~07 标注已取消, 决策 35):
   ExtrasCard 组件删除, DB extras 字段保留不再使用;
   项目相关长文本由项目信息块 (T-PJ-13) 承载
5. 任务移动入口: 详情页新增「看板列」ActionSheet 换列(联动裁决不变)
   与「层级」区(转为子任务=选父任务 / 转为顶层), 覆盖无法拖拽的场景
6. 列表页移除「+ 子任务」入口: 添加子任务只在详情页, 或经转子任务

已验证: 99 文件语法 0 错误、导入 0 缺失、文档 149 条/决策 35 连续
This commit is contained in:
jif2.zhang
2026-10-24 21:51:26 +08:00
parent b71ceca3f2
commit 506db0795b
10 changed files with 252 additions and 146 deletions
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react'
import { DotLoading, Popup, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
// 跨项目拷贝弹层 (T-CP-*): 选择目标项目 → 深拷贝; 人员不属于目标项目时丢弃并提示
export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
const [projects, setProjects] = useState(null)
useEffect(() => {
if (!visible) return
setProjects(null)
projectApi.list()
.then((d) => setProjects((d.items || []).filter((p) => p.id !== task.projectId && !p.archived)))
.catch((e) => {
Toast.show({ content: e.message })
setProjects([])
})
}, [visible, task.projectId])
async function doCopy(projectId, name) {
try {
const r = await taskApi.copy(task.id, projectId)
const dropped = r.dropped || {}
const lost = [dropped.assignee?.name, ...(dropped.members || []).map((m) => m.name)].filter(Boolean)
Toast.show({
content: lost.length
? `已拷贝到「${name}」;${lost.join('、')} 不是该项目成员,已不保留`
: `已拷贝到「${name}」`,
})
onClose?.()
onDone?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<Popup
visible={visible}
onMaskClick={onClose}
bodyStyle={{
borderTopLeftRadius: 12, borderTopRightRadius: 12,
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
}}
>
<div className="sheet-title">跨项目拷贝</div>
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
{!projects && <div className="empty" style={{ padding: '20px 0' }}><DotLoading color="#1677ff" /> 加载项目…</div>}
{projects && projects.length === 0 && <div className="extras-empty">没有其他可拷贝的项目</div>}
{(projects || []).map((p) => (
<div className="pick-row" key={p.id} onClick={() => doCopy(p.id, p.name)}>
<div className="avatar">{(p.name || '?').slice(0, 1)}</div>
<span className="pn">{p.name}</span>
<span className="link">拷贝</span>
</div>
))}
<div className="btn-ghost" onClick={onClose}>取消</div>
</Popup>
)
}
-59
View File
@@ -1,59 +0,0 @@
import { useState } from 'react'
// 扩展信息卡片 (T-TK-04~07): 任意条目, 默认收起, 全部非必填
// 条目结构 { label, value, url? }
export default function ExtrasCard({ extras = [], onChange, readOnly = false }) {
const [open, setOpen] = useState(false)
const list = Array.isArray(extras) ? extras : []
const update = (idx, key, val) => {
const next = list.map((e, i) => (i === idx ? { ...e, [key]: val } : e))
onChange?.(next)
}
const add = () => onChange?.([...list, { label: '', value: '', url: '' }])
const remove = (idx) => onChange?.(list.filter((_, i) => i !== idx))
return (
<div className="extras">
<div className="extras-head" onClick={() => setOpen((v) => !v)}>
<span className="caret">{open ? '▾' : '▸'}</span>
<span>扩展信息</span>
{list.length > 0 && <span className="tag">{list.length}</span>}
<span className="cv">{open ? '收起' : '默认收起 · 全部非必填'}</span>
</div>
{open && (
<div className="extras-body">
{list.length === 0 && (
<div className="extras-empty">暂无条目,可记录仓库 / 分支 / 链接等任意信息</div>
)}
{list.map((e, i) => (
<div className="extra-row" key={i}>
<input
className="inp extra-k"
placeholder="名称"
value={e.label || ''}
disabled={readOnly}
onChange={(ev) => update(i, 'label', ev.target.value)}
/>
<input
className="inp extra-v"
placeholder="值"
value={e.value || ''}
disabled={readOnly}
onChange={(ev) => update(i, 'value', ev.target.value)}
/>
{!readOnly && (
<button type="button" className="extra-del" onClick={() => remove(i)}>×</button>
)}
</div>
))}
{!readOnly && (
<button type="button" className="extras-add" onClick={add}>+ 添加一条</button>
)}
</div>
)}
</div>
)
}
+7 -10
View File
@@ -7,7 +7,7 @@ import StatusDot from './StatusDot.jsx'
const MAX_SUB = 3
export default function TaskCard({
task, bindDrag, onClick, onToggleSub, onToggle, onAddSub, 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)
@@ -38,6 +38,12 @@ export default function TaskCard({
{task.members?.length > 0 && (
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
)}
{onMore && (
<span
className="bmore-btn"
onClick={(e) => { e.stopPropagation(); onMore(task) }}
>⋯</span>
)}
</div>
{shown.map((c) => (
@@ -52,15 +58,6 @@ export default function TaskCard({
</div>
))}
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
{onAddSub && (
<div
className="bsub-add"
onClick={(e) => { e.stopPropagation(); onAddSub(task) }}
>
+ 子任务
</div>
)}
</div>
)
}
+2 -1
View File
@@ -6,7 +6,7 @@ import useLongPressDrag from '../../components/task/useLongPressDrag.js'
// 看板视图 (T-BD-01, T-UI-07, T-ST-06/12)
// 列横向滚动; 长按卡片拖拽换列 / 降为子任务
export default function BoardView({
tasks, columns, onMoveColumn, onMakeSubtask, onOpen, onToggleSub, onToggle, onQuickAdd,
tasks, columns, onMoveColumn, onMakeSubtask, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
}) {
const [overCol, setOverCol] = useState(null)
@@ -54,6 +54,7 @@ export default function BoardView({
onClick={() => { if (!drag.dragging) onOpen?.(t) }}
onToggleSub={onToggleSub}
onToggle={onToggle}
onMore={onMore}
/>
))}
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
+5 -8
View File
@@ -2,10 +2,10 @@ import { useState } from 'react'
import TaskRow from '../../components/task/TaskRow.jsx'
// 列表视图 (T-VW-01, T-ST-04/05)
// 按看板列分组展示(列表不分组也允许, 这里默认分组更接近 Todoist)
// 子任务缩进 + 折叠展开
// 按看板列分组展示; 子任务缩进 + 折叠展开
// 子任务的添加只在任务详情页完成 (或把别的任务转为子任务), 列表页不放入口
export default function ListView({
tasks, columns, onToggle, onOpen, onAddSub,
tasks, columns, onToggle, onOpen, onMore,
}) {
const [collapsed, setCollapsed] = useState({})
@@ -47,12 +47,12 @@ export default function ListView({
task={t}
onToggle={onToggle}
onClick={() => onOpen(t)}
onMore={() => onOpen(t)}
onMore={onMore}
/>
{kids.length > 0 && (
<>
{!isCollapsed && kids.map((c) => (
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} />
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} />
))}
<div
className="collapse"
@@ -62,9 +62,6 @@ export default function ListView({
</div>
</>
)}
{kids.length === 0 && onAddSub && (
<div className="sub-add-hint" onClick={() => onAddSub(t)}>+ 子任务</div>
)}
</div>
)
})}
+53 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { DotLoading, Toast } from 'antd-mobile'
import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
import { filterVisible, msToNextBoundary } from './date.js'
import { getViewMode, setViewMode } from './prefs.js'
@@ -7,6 +7,7 @@ import ListView from './ListView.jsx'
import BoardView from './BoardView.jsx'
import TaskDetail from './TaskDetail.jsx'
import ProjectManage from './ProjectManage.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
// 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11)
@@ -20,6 +21,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const [showManage, setShowManage] = useState(false)
const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
const load = useCallback(() => {
setErr('')
@@ -136,6 +138,44 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
}
}
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */
function openTaskMenu(task) {
ActionSheet.show({
extra: (
<div className="as-extra">
<div className="as-title">{task.title}</div>
</div>
),
actions: [
{ text: '编辑', key: 'edit' },
{ text: '拷贝到其他项目', key: 'copy' },
{ text: '删除', key: 'delete', bold: true, danger: true },
],
cancelText: '取消',
onAction: async (item) => {
if (item.key === 'edit') setOpenTask(task)
else if (item.key === 'copy') setCopyTask(task)
else if (item.key === 'delete') {
const kids = task.children?.length || 0
const ok = await Dialog.confirm({
content: kids
? `删除「${task.title}」?将一并删除 ${kids} 条子任务。`
: `删除「${task.title}」?`,
confirmText: '删除',
})
if (!ok) return
try {
await taskApi.remove(task.id)
Toast.show({ content: '已删除' })
load()
} catch (e) {
Toast.show({ content: e.message })
}
}
},
})
}
if (err) {
return (
<div className="pv-embed">
@@ -196,7 +236,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
columns={columns}
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
onAddSub={(parent) => setOpenTask(parent)}
onMore={openTaskMenu}
/>
) : (
<BoardView
@@ -207,6 +247,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onOpen={(t) => setOpenTask(t)}
onToggleSub={(c) => toggleDone(c)}
onToggle={(t) => toggleDone(t)}
onMore={openTaskMenu}
onQuickAdd={() => { setAdding(true) }}
/>
)}
@@ -217,12 +258,22 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
task={openTask}
columns={columns}
members={project.members || []}
tasks={tasks || []}
onClose={() => setOpenTask(null)}
onSaved={() => { setOpenTask(null); load() }}
onChanged={load}
/>
)}
{copyTask && (
<CopyTaskPopup
task={copyTask}
visible
onClose={() => setCopyTask(null)}
onDone={load}
/>
)}
{showManage && (
<ProjectManage
project={project}
+102 -58
View File
@@ -1,19 +1,21 @@
import { useEffect, useState } from 'react'
import { Dialog, Popup, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
import { ActionSheet, Dialog, Popup, Toast } from 'antd-mobile'
import { taskApi } from '../../api/task.js'
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
import ExtrasCard from '../../components/task/ExtrasCard.jsx'
import UserPicker from '../../components/task/UserPicker.jsx'
import StatusDot from '../../components/task/StatusDot.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js'
// 任务详情 (T-TK-01~13, T-ST-03, T-AS-05)
export default function TaskDetail({ task, columns, members, onClose, onSaved, onChanged }) {
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
export default function TaskDetail({ task, columns, members, tasks, onClose, onSaved, onChanged }) {
const [form, setForm] = useState(() => ({
title: task.title || '',
description: task.description || '',
extras: task.extras || [],
memberIds: (task.members || []).map((m) => m.userId),
assigneeId: task.assignee?.id ?? null,
status: task.status,
@@ -22,22 +24,28 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
const [subs, setSubs] = useState(task.children || [])
const [newSub, setNewSub] = useState('')
const [saving, setSaving] = useState(false)
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
const [copies, setCopies] = useState(null) // 其他项目列表(拷贝用)
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
const [copyOpen, setCopyOpen] = useState(false)
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡 (bug#4)
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
const kb = useKeyboardInset()
// 返回键: 先关详情弹层, 不离开任务页
useOverlayBack(true, onClose)
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 isSubtask = task.parentId != null
// 可作为父任务的候选: 同项目顶层任务, 排除自己与已有子任务的任务 (T-ST-01/13)
const parentCandidates = (tasks || []).filter(
(t) => t.id !== task.id && (t.children || []).length === 0,
)
useEffect(() => {
setForm({
title: task.title || '',
description: task.description || '',
extras: task.extras || [],
memberIds: (task.members || []).map((m) => m.userId),
assigneeId: task.assignee?.id ?? null,
status: task.status,
@@ -53,7 +61,6 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
const r = await taskApi.update(task.id, {
title: form.title.trim(),
description: form.description,
extras: form.extras,
assigneeId: form.assigneeId,
memberIds: form.memberIds,
status: form.status,
@@ -68,6 +75,39 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
}
}
/** 换列: 服务端按目标列绑定裁决状态 (T-BD-04/07) */
function pickColumn() {
ActionSheet.show({
actions: (columns || []).map((c) => ({
text: c.name + (c.id === task.columnId ? ' ✓' : ''),
key: String(c.id),
})),
cancelText: '取消',
onAction: async (item) => {
const columnId = Number(item.key)
if (columnId === task.columnId) return
try {
const r = await taskApi.update(task.id, { columnId })
Toast.show({ content: `已移入 ${(columns || []).find((c) => c.id === columnId)?.name || '目标列'}` })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
}
},
})
}
/** 转为子任务 (T-ST-12) / 转为顶层 */
async function changeParent(parentId) {
try {
const r = await taskApi.update(task.id, { parentId })
Toast.show({ content: parentId ? '已转为子任务' : '已转为顶层任务' })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
}
}
async function addSub() {
const t = newSub.trim()
if (!t) return
@@ -109,32 +149,6 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
}
}
async function openCopy() {
try {
const d = await projectApi.list()
setCopies((d.items || []).filter((p) => p.id !== task.projectId && !p.archived))
} catch (e) {
Toast.show({ content: e.message })
}
}
async function doCopy(projectId) {
try {
const r = await taskApi.copy(task.id, projectId)
const dropped = r.dropped || {}
const lost = [dropped.assignee?.name, ...(dropped.members || []).map((m) => m.name)].filter(Boolean)
Toast.show({
content: lost.length
? `已拷贝到目标项目;${lost.join('、')} 不是该项目成员,已不保留`
: '已拷贝到目标项目',
})
setCopies(null)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<Popup
visible
@@ -145,13 +159,12 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
borderTopRightRadius: 12,
maxHeight: `calc(88vh - ${kb}px)`,
overflowY: 'auto',
// 键盘弹出时抬升内容 (kb), 否则"添加子任务"输入框被键盘遮挡
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
>
<div className="sheet-title">{task.title}</div>
<div className="sheet-sub">
{task.column?.name} · {STATUS_LABEL[task.status]}
{currentCol?.name} · {STATUS_LABEL[task.status]}
</div>
<div className="field">
@@ -175,6 +188,7 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
/>
</div>
{/* 状态: 三段式, 当前项高亮, 点按即切换 */}
<div className="field">
<label>状态</label>
<div className="seg">
@@ -190,6 +204,31 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
</div>
</div>
{/* 看板列: 点击换列, 状态随目标列联动 */}
<div className="field">
<label>看板列</label>
<div className="pick-row" onClick={pickColumn}>
<span className="pn">{currentCol?.name || '未分组'}</span>
<span className="link">更换 ›</span>
</div>
</div>
{/* 层级: 转为子任务 / 转为顶层 (T-ST-12) */}
<div className="field">
<label>层级</label>
{isSubtask ? (
<div className="pick-row" onClick={() => changeParent(null)}>
<span className="pn">子任务 · 可转为顶层任务</span>
<span className="link">转为顶层</span>
</div>
) : (
<div className="pick-row" onClick={() => setParentPick(true)}>
<span className="pn">顶层任务 · 可挂到其他任务下</span>
<span className="link">转为子任务 ›</span>
</div>
)}
</div>
<div className="field">
<label>负责人</label>
<div className="pick-row" onClick={() => setPicker('assignee')}>
@@ -216,11 +255,6 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
</div>
</div>
<ExtrasCard
extras={form.extras}
onChange={(next) => setForm((p) => ({ ...p, extras: next }))}
/>
<div className="section-h">子任务 <span className="tag">{subs.length}</span></div>
{subs.map((s) => (
<div className="pick-row" key={s.id} onClick={() => toggleSub(s)}>
@@ -238,14 +272,13 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
onChange={(e) => setNewSub(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
onFocus={(e) => {
// 键盘弹出动画 (~300ms) 后把输入框滚到可视区中部, 双保险防遮挡
setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300)
}}
/>
</div>
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</div>
<div className="btn-ghost" onClick={openCopy}>拷贝到其他项目</div>
<div className="btn-ghost" onClick={() => setCopyOpen(true)}>拷贝到其他项目</div>
<div className="btn-danger" onClick={removeTask}>
删除任务{subs.length > 0 && <span className="del-hint">(将一并删除 {subs.length} 条子任务)</span>}
</div>
@@ -268,22 +301,33 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
/>
<CopyTaskPopup
task={task}
visible={copyOpen}
onClose={() => setCopyOpen(false)}
onDone={onChanged}
/>
{/* 转为子任务: 从项目内顶层任务中选父任务 */}
<Popup
visible={!!copies}
onMaskClick={() => setCopies(null)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}
visible={parentPick}
onMaskClick={() => setParentPick(false)}
bodyStyle={{
borderTopLeftRadius: 12, borderTopRightRadius: 12,
maxHeight: '70vh', overflowY: 'auto',
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
}}
>
<div className="sheet-title">跨项目拷贝</div>
<div className="sheet-sub">源任务保持不变;新任务的创建人是你</div>
{(copies || []).length === 0 && <div className="extras-empty">没有其他可拷贝的项目</div>}
{(copies || []).map((p) => (
<div className="pick-row" key={p.id} onClick={() => doCopy(p.id)}>
<div className="avatar">{(p.name || '?').slice(0, 1)}</div>
<span className="pn">{p.name}</span>
<span className="link">拷贝</span>
<div className="sheet-title">转为子任务</div>
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
{parentCandidates.length === 0 && <div className="extras-empty">没有可选的父任务</div>}
{parentCandidates.map((t) => (
<div className="pick-row" key={t.id} onClick={() => { setParentPick(false); changeParent(t.id) }}>
<span className="pn">{t.title}</span>
<span className="link">选定</span>
</div>
))}
<div className="btn-ghost" onClick={() => setCopies(null)}>取消</div>
<div className="btn-ghost" onClick={() => setParentPick(false)}>取消</div>
</Popup>
</Popup>
)
+13
View File
@@ -1143,3 +1143,16 @@ textarea.inp { resize: vertical; }
font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
/* ---- 通用分段控件 (状态选择等): 当前项高亮 ----
此前只有 .pv-head .seg 有定义, 详情弹层里裸用 .seg 会堆成三行 */
.seg { display: flex; background: #f2f3f5; border-radius: 8px; padding: 3px; }
.seg div { flex: 1; text-align: center; font-size: 13px; padding: 6px 0; border-radius: 6px; color: var(--color-muted); cursor: pointer; }
.seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
/* ---- 看板卡片 ⋯ 菜单入口 ---- */
.bmore-btn { margin-left: auto; color: #c2c7cf; font-size: 14px; line-height: 1; padding: 2px 4px; cursor: pointer; }
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
.as-extra { padding: 4px 0 8px; }
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }