fix(V0.2): BUILD_ID 时区 / 新建弹框残留 / 项目信息块
Docker Build and Push / build-image (push) Successful in 4m19s
Docker Build and Push / build-image (push) Successful in 4m19s
1. BUILD_ID 改用 UTC+8 生成: CI runner 是 UTC 时区, 原来取本地时间会错位;
统一偏移 +8h 后按 UTC 读取 (vite.config.js makeBuildId)
2. 修复「我的项目」页反复弹新建框: createSignal 一次性消费,
ProjectList 处理后回调清零 (此前 TaskPage 状态在 /task 与 /task/list
间保留, 信号残留导致每次进入都触发)
3. 新增项目信息块 (T-PJ-13):
- Project.notes Json ([{title,content}], <=50 块 / 标题<=50 字 / 内容<=20000 字)
- 迁移 20261024130000: 加列(NULL)->回填'[]'->收紧 NOT NULL, 存量行安全
- GET 详情返回 notes; PUT 接受 notes 整体替换
- 项目管理新增「信息块」分区: 列表预览 + 底部弹层独立编辑(标题+多行
textarea+删除), 键盘弹出复用 useKeyboardInset 抬升
- 文档: 需求 T-PJ-13 (149 条) + 功能设计对应行
已验证: 109 文件语法 0 错误、文档 149/149 覆盖
This commit is contained in:
@@ -4,14 +4,14 @@
|
||||
| --- | --- |
|
||||
| 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
|
||||
| 文档状态 | **设计基线**(待评审) |
|
||||
| 上游文档 | `任务模块需求文档.md`(**148 条需求点**,编号 `T-*`) |
|
||||
| 上游文档 | `任务模块需求文档.md`(**149 条需求点**,编号 `T-*`) |
|
||||
| 配套文档 | `任务模块设计文档-框架设计.md`、`任务模块数据库设计文档.md`、`任务模块界面设计.html` |
|
||||
| 对应关系 | **本文与需求点一一对应**:每条需求点均给出「设计落点(功能/组件/接口/表字段)」与「实现要点」 |
|
||||
|
||||
> **阅读方式**
|
||||
> 1. 本文按需求文档的模块码组织(`VW` / `COL` / `BD` / `FL` / `TK` / `AS` / `ST` / `PJ` / `PM` / `CP` / `UI` / `DT` / `NF` / `EX`)。
|
||||
> 2. 每条需求点一行:**编号 → 设计落点 → 实现要点**。设计落点中的组件名、接口路径、表字段均与《任务模块数据库设计文档》《任务模块界面设计》一致。
|
||||
> 3. 文末 §15 给出**覆盖自检表**,确保 148 条无遗漏。
|
||||
> 3. 文末 §15 给出**覆盖自检表**,确保 149 条无遗漏。
|
||||
|
||||
---
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
| T-PJ-10 | 列表查询按成员关系过滤 | `WHERE EXISTS ProjectMember(userId)` |
|
||||
| T-PJ-11 | 新组件 `ProjectManage`(项目管理页) | 分区承载:成员(复用 `ProjectMembers` 逻辑)、列设置(复用 `ColumnSettings`)、项目信息;`ProjectView` 头部仅保留「项目管理」一个入口 |
|
||||
| T-PJ-12 | `ProjectManage` 的「项目信息」分区 | 改名(`PUT /api/projects/:id {name}`)、归档/取消归档(`{archived}`)、删除(个人项目隐藏入口)、退出(仅创建人可见);删除与退出均有二次确认 |
|
||||
| T-PJ-13 | `Project.notes` Json + `ProjectManage` 的「信息块」分区 | 存 `[{title, content}]`(≤50 块,标题 ≤50 字、内容 ≤20000 字);`PUT {notes}` 整体替换;底部弹层独立编辑(标题输入 + 多行 textarea + 删除),键盘弹出时复用 `useKeyboardInset` 抬升 |
|
||||
|
||||
---
|
||||
|
||||
@@ -326,13 +327,13 @@
|
||||
| 任务字段体系 | T-TK-01 ~ 13 | 13 | §5 |
|
||||
| 负责人 & 成员 | T-AS-01 ~ 07 | 7 | §6 |
|
||||
| 子任务体系 | T-ST-01 ~ 15 | 15 | §7 |
|
||||
| 项目体系 | T-PJ-01 ~ 12 | 12 | §8 |
|
||||
| 项目体系 | T-PJ-01 ~ 13 | 13 | §8 |
|
||||
| 项目权限与入口 | T-PM-01 ~ 07 | 7 | §9 |
|
||||
| 跨项目拷贝 | T-CP-01 ~ 09 | 9 | §10 |
|
||||
| UI 交互规范 | T-UI-01 ~ 11 | 11 | §11 |
|
||||
| 数据需求 | T-DT-01 ~ 09 | 9 | §12 |
|
||||
| 非功能需求 | T-NF-01 ~ 05 | 5 | §13 |
|
||||
| 异常与边界 | T-EX-01 ~ 19 | 19 | §14 |
|
||||
| **合计** | — | **148** | — |
|
||||
| **合计** | — | **149** | — |
|
||||
|
||||
> 需求文档共 **148 条**需求点,本文逐条给出设计落点,覆盖率 **148/148**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11` 为评审后新增,编号不连续,非笔误。)
|
||||
> 需求文档共 **149 条**需求点,本文逐条给出设计落点,覆盖率 **149/149**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11` 为评审后新增,编号不连续,非笔误。)
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
| T-PJ-10 | 项目列表按用户可见范围返回,仅含自己为成员的项目 | 非成员项目不出现在列表中 | P0 |
|
||||
| T-PJ-11 | 项目的**成员管理、成员新增、列设置**统一收进「**项目管理**」入口;项目页头部不再散置多个功能入口 | 项目管理内含 成员 / 列设置 / 项目信息 三个分区;头部仅保留「项目管理」一个入口 | P0 |
|
||||
| T-PJ-12 | 项目管理内提供**项目信息管理**:改名(T-PJ-04)、归档 / 取消归档(T-PJ-09)、删除项目(T-PJ-02,个人默认项目禁用)、退出项目(T-PJ-07,创建人可退) | 各操作在项目管理中可用且遵循既有约束;个人项目不出现删除与退出入口 | P0 |
|
||||
| T-PJ-13 | 项目支持**信息块**:可自由添加任意多个长文本信息块(标题 + 多行内容,如 Readme / 项目说明),每块**独立编辑与删除**;属项目级数据,全体成员可见可编辑 | 可增删改任意块;内容支持多行长文本;所有成员看到同一份信息块 | P0 |
|
||||
|
||||
### 3.9 项目权限(T-PM)
|
||||
|
||||
@@ -384,7 +385,7 @@
|
||||
|
||||
## 4. 优先级汇总(本期交付范围)
|
||||
|
||||
- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-FL-01~13、15;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~12;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17。
|
||||
- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-FL-01~13、15;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~13;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17。
|
||||
- **P1(应该)**:T-VW-05、10;T-COL-08;T-BD-11、12;T-FL-14;T-TK-12、13;T-AS-06;T-ST-10、15;T-PJ-09;T-PM-03;T-CP-07、09;T-UI-06;T-DT-09;T-NF-05;T-EX-06、08、13、14、18、19。
|
||||
|
||||
---
|
||||
@@ -430,6 +431,7 @@
|
||||
| 31 | 成员 / 列设置入口的位置? | 统一收进「**项目管理**」入口(含成员、列设置、项目信息三区),项目页头部不再散置(T-PJ-11) |
|
||||
| 32 | 项目信息管理包含什么? | 改名、归档 / 取消归档、删除(个人项目禁用)、退出(创建人可退),入口在项目管理内(T-PJ-12) |
|
||||
| 33 | 任务详情底部按钮遮挡? | 弹层底部预留**安全区 + 12px** 距离;小屏下弹层可滚动,底部按钮完整可见(T-UI-11) |
|
||||
| 34 | 项目相关长文本信息记录在哪? | 项目管理内新增**信息块**分区:任意多个 {标题, 长文本} 块,独立编辑,属项目级数据(T-PJ-13) |
|
||||
|
||||
**仍待评审确认(影响实现口径):**
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 项目信息块 (T-PJ-13): Project 增加 notes JSON, 存 [{ title, content }] 任意多个长文本块
|
||||
-- 分三步保证存量行安全: 先可空加列 -> 回填 '[]' -> 收紧为 NOT NULL
|
||||
ALTER TABLE `Project` ADD COLUMN `notes` JSON NULL;
|
||||
|
||||
UPDATE `Project` SET `notes` = '[]' WHERE `notes` IS NULL;
|
||||
|
||||
ALTER TABLE `Project` MODIFY `notes` JSON NOT NULL;
|
||||
@@ -393,6 +393,7 @@ model Project {
|
||||
owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
isPersonal Boolean @default(false)
|
||||
archived Boolean @default(false)
|
||||
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -107,18 +107,19 @@ router.get('/:id', async (req, res, next) => {
|
||||
...projectSummary(project),
|
||||
members: members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission })),
|
||||
columns,
|
||||
notes: Array.isArray(project.notes) ? project.notes : [],
|
||||
},
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PUT /api/projects/:id { name?, archived? }
|
||||
// PUT /api/projects/:id { name?, archived?, notes? }
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
const { name, archived } = req.body || {}
|
||||
const { name, archived, notes } = req.body || {}
|
||||
const data = {}
|
||||
if (name !== undefined) {
|
||||
const t = String(name).trim()
|
||||
@@ -127,6 +128,14 @@ router.put('/:id', async (req, res, next) => {
|
||||
data.name = t
|
||||
}
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
||||
if (notes !== undefined) {
|
||||
if (!Array.isArray(notes)) return res.status(400).json({ error: '信息块格式非法' })
|
||||
data.notes = notes.slice(0, 50).map((n) => ({
|
||||
title: String(n?.title ?? '').slice(0, 50),
|
||||
content: String(n?.content ?? '').slice(0, 20000),
|
||||
}))
|
||||
}
|
||||
const row = await prisma.project.update({ where: { id: project.id }, data })
|
||||
res.json({ item: projectSummary(row) })
|
||||
} catch (e) { next(e) }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, Input, Toast } from 'antd-mobile'
|
||||
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
|
||||
import { projectApi } from '../../api/task.js'
|
||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
||||
import ColumnSettings from './ColumnSettings.jsx'
|
||||
import ProjectMembers from './ProjectMembers.jsx'
|
||||
@@ -13,13 +14,18 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
const [view, setView] = useState('main') // main | columns | members
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [name, setName] = useState(project.name)
|
||||
// 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块
|
||||
const [noteEdit, setNoteEdit] = useState(null)
|
||||
|
||||
const members = project.members || []
|
||||
const notes = Array.isArray(project.notes) ? project.notes : []
|
||||
const isOwner = user?.id === project.ownerId
|
||||
const isPersonal = !!project.isPersonal
|
||||
|
||||
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
|
||||
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
|
||||
// 键盘弹出插入量: 信息块编辑弹层用
|
||||
const kb = useKeyboardInset()
|
||||
|
||||
if (view === 'columns') {
|
||||
return (
|
||||
@@ -86,6 +92,37 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
}
|
||||
}
|
||||
|
||||
// 信息块保存 (T-PJ-13): 整体替换 notes 数组
|
||||
async function saveNote() {
|
||||
const { index, title, content } = noteEdit
|
||||
const next = notes.map((n) => ({ title: n.title || '', content: n.content || '' }))
|
||||
const block = { title: String(title || '').trim(), content: String(content || '') }
|
||||
if (!block.title && !block.content) { Toast.show({ content: '请填写标题或内容' }); return }
|
||||
if (index >= 0) next[index] = block
|
||||
else next.push(block)
|
||||
try {
|
||||
await projectApi.update(project.id, { notes: next })
|
||||
Toast.show({ content: '已保存' })
|
||||
setNoteEdit(null)
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNote() {
|
||||
const { index } = noteEdit
|
||||
const next = notes.filter((_, i) => i !== index)
|
||||
try {
|
||||
await projectApi.update(project.id, { notes: next })
|
||||
Toast.show({ content: '已删除' })
|
||||
setNoteEdit(null)
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function quit() {
|
||||
const ok = await Dialog.confirm({
|
||||
content: '退出该项目?退出后将不再可见。',
|
||||
@@ -130,6 +167,25 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
<span className="link">列设置 ›</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div>
|
||||
{notes.map((n, i) => (
|
||||
<div
|
||||
className="set-row note-row"
|
||||
key={i}
|
||||
style={{ padding: '11px 14px', alignItems: 'flex-start' }}
|
||||
onClick={() => setNoteEdit({ index: i, title: n.title || '', content: n.content || '' })}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="note-title">{n.title || '未命名'}</div>
|
||||
<div className="note-preview">{String(n.content || '').split('\n')[0] || '(空)'}</div>
|
||||
</div>
|
||||
<span className="link" style={{ flexShrink: 0, marginTop: 2 }}>编辑</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="set-row" style={{ padding: '11px 14px' }} onClick={() => setNoteEdit({ index: -1, title: '', content: '' })}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: 'var(--color-primary)' }}>+ 添加信息块</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
|
||||
@@ -171,6 +227,49 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
]}
|
||||
onClose={() => setRenaming(false)}
|
||||
/>
|
||||
|
||||
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
|
||||
<Popup
|
||||
visible={!!noteEdit}
|
||||
onMaskClick={() => setNoteEdit(null)}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
maxHeight: '86vh',
|
||||
overflowY: 'auto',
|
||||
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
|
||||
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
|
||||
|
||||
<div className="field">
|
||||
<label>标题</label>
|
||||
<Input
|
||||
placeholder="如:项目说明 / Readme"
|
||||
value={noteEdit?.title || ''}
|
||||
maxLength={50}
|
||||
onChange={(v) => setNoteEdit((p) => ({ ...(p || {}), title: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>内容</label>
|
||||
<textarea
|
||||
className="inp"
|
||||
rows={10}
|
||||
placeholder="支持多行长文本…"
|
||||
value={noteEdit?.content || ''}
|
||||
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="btn-primary" onClick={saveNote}>保存</div>
|
||||
{noteEdit?.index >= 0 && (
|
||||
<div className="btn-danger" onClick={removeNote}>删除此信息块</div>
|
||||
)}
|
||||
<div className="btn-ghost" onClick={() => setNoteEdit(null)}>取消</div>
|
||||
</Popup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export default function TaskPage() {
|
||||
onOpen={(id) => { selectProject(id); navigate('/task') }}
|
||||
onChanged={load}
|
||||
createSignal={createSignal}
|
||||
onCreateConsumed={() => setCreateSignal(0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,7 +133,7 @@ const MOVE_TOLERANCE = 8
|
||||
|
||||
// 完整项目列表 (从「更多项目」进入)
|
||||
// 顺序: 手动排序(recentOrder, 拖动结果)优先 > 最近访问 > 列表序; 已归档固定在底部
|
||||
function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal }) {
|
||||
function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal, onCreateConsumed }) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -144,8 +145,14 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal }
|
||||
const activeRef = useRef(false)
|
||||
const dragIdRef = useRef(null)
|
||||
|
||||
// 头部「+ 新建」→ 弹起新建对话框
|
||||
useEffect(() => { if (createSignal) setCreating(true) }, [createSignal])
|
||||
// 头部「+ 新建」→ 弹起新建对话框 (一次性: 消费后清零, 否则下次进入该页会再次弹出)
|
||||
useEffect(() => {
|
||||
if (createSignal) {
|
||||
setCreating(true)
|
||||
onCreateConsumed?.()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [createSignal])
|
||||
|
||||
const active = useMemo(() => {
|
||||
const manual = getRecentOrder()
|
||||
|
||||
@@ -1135,3 +1135,11 @@ textarea.inp { resize: vertical; }
|
||||
|
||||
/* ---- 项目列表拖动排序 (bug#3) ---- */
|
||||
.proj-item.dragging { opacity: .65; transform: scale(.98); box-shadow: 0 8px 18px rgba(15,23,42,.16); z-index: 5; position: relative; }
|
||||
|
||||
/* ---- 项目信息块 (T-PJ-13) ---- */
|
||||
.note-row { cursor: pointer; }
|
||||
.note-title { font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.note-preview {
|
||||
font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
+3
-2
@@ -4,9 +4,10 @@ import react from '@vitejs/plugin-react'
|
||||
// 每次编译生成唯一 build 号 (YYYYMMDD.HHmmss), 通过 define 注入到 __BUILD_ID__
|
||||
// 版本号 APP_VERSION 唯一管理点在 package.json 的 version 字段 (手动修改)
|
||||
function makeBuildId() {
|
||||
const d = new Date()
|
||||
// 统一用 UTC+8 (北京时间) 生成: CI runner 是 UTC 时区, 直接取本地时间会把日期/小时错位
|
||||
const d = new Date(Date.now() + 8 * 3600 * 1000)
|
||||
const p = (n, w) => String(n).padStart(w || 2, '0')
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}.${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
||||
}
|
||||
const BUILD_ID = makeBuildId()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user