From a714013e3ecf2a0daadfa2fa54f4eaf747b267dc Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Mon, 26 Oct 2026 00:41:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(0.2.1):=20M3=20=E5=89=8D=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20=E2=80=94=E2=80=94=20PowerSyncProvider/=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=BF=9E=E6=8E=A5/=E7=99=BB=E5=87=BA=E6=B8=85?= =?UTF-8?q?=E5=BA=93,=20=E4=BB=BB=E5=8A=A1=E6=A8=A1=E5=9D=97=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=B1=82=E5=88=87=E6=9C=AC=E5=9C=B0=E5=BA=93=20(proje?= =?UTF-8?q?ctApi/columnApi/taskApi=20=E6=9C=AC=E5=9C=B0=E5=AE=9E=E7=8E=B0,?= =?UTF-8?q?=20=E7=A6=BB=E7=BA=BF=E8=AF=BB=E5=86=99+=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E4=B8=8A=E8=A1=8C),=20=E4=BB=BB=E5=8A=A1=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E5=BE=BD=E6=A0=87=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 7 +- src/api/client.js | 10 + src/api/task.js | 702 +++++++++++++++++++++++++++++-- src/auth/AuthContext.jsx | 13 +- src/components/task/TaskCard.jsx | 5 +- src/components/task/TaskRow.jsx | 5 +- src/lib/powersync/connector.js | 32 +- src/lib/powersync/react.jsx | 26 ++ src/pages/task/BoardView.jsx | 3 +- src/pages/task/ListView.jsx | 3 +- src/pages/task/ProjectView.jsx | 2 + src/styles/globals.css | 7 + vite.config.js | 4 + 13 files changed, 771 insertions(+), 48 deletions(-) create mode 100644 src/lib/powersync/react.jsx diff --git a/src/App.jsx b/src/App.jsx index acf78be..068818e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,7 @@ import MePage from './pages/MePage.jsx' import CheckinPage from './pages/checkin/CheckinPage.jsx' import TaskPage from './pages/task/TaskPage.jsx' import { useAuth } from './auth/AuthContext.jsx' +import { PowerSyncProvider } from './lib/powersync/react.jsx' // 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中) const HOME_PATH = '/checkin' @@ -31,7 +32,8 @@ function RequireAuth({ children }) { export default function App() { return ( - + + } /> }> {/* 打卡为默认首页 */} @@ -49,6 +51,7 @@ export default function App() { } /> } /> - + + ) } \ No newline at end of file diff --git a/src/api/client.js b/src/api/client.js index f1e0a63..2d5873e 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -26,6 +26,16 @@ export function setRememberFlag(v) { else localStorage.removeItem(REMEMBER_KEY) } +// 当前用户 id (0.2.1 离线写入需要 ownerId/creatorId, 本地库无法即时从服务端获取) +const UID_KEY = 'iboard_uid' +export function getCurrentUserId() { + return localStorage.getItem(UID_KEY) || '' +} +export function setCurrentUserId(id) { + if (id !== null && id !== undefined && id !== '') localStorage.setItem(UID_KEY, String(id)) + else localStorage.removeItem(UID_KEY) +} + export function getApiBase() { return API_BASE } diff --git a/src/api/task.js b/src/api/task.js index 1c88765..9cd736d 100644 --- a/src/api/task.js +++ b/src/api/task.js @@ -1,41 +1,683 @@ -import { api } from './client.js' +// 任务模块数据层 (0.2.1): 已切换为 PowerSync 本地 SQLite +// - 读: 全部来自本地库 (离线可用); 写: 本地写入 -> upload queue -> /api/powersync/upload (按 uuid 幂等) +// - 对页面保持与原 REST 版一致的调用签名与响应形状 (projectApi / columnApi / taskApi) +// - 编号: Project.code / Task.number 由服务端统一分配, 本地新建行同步前分别为空 -> 展示占位 +// 见 docs/V0.2/离线编辑模块/0.2.1实施方案.md +import { getPowersyncDb } from '../lib/powersync/db.js' +import { api, getCurrentUserId } from './client.js' +import { filterVisible } from '../pages/task/date.js' -const q = (params) => { - const s = new URLSearchParams() - Object.entries(params || {}).forEach(([k, v]) => { - if (v !== undefined && v !== null && v !== '') s.set(k, v) - }) - const str = s.toString() - return str ? '?' + str : '' +const db = () => getPowersyncDb() + +const uuid = () => crypto.randomUUID() +const now = () => new Date().toISOString() +const parseJson = (v, fallback) => { + if (v === null || v === undefined || v === '') return fallback + if (typeof v !== 'string') return v + try { return JSON.parse(v) } catch { return fallback } } -// 项目 +// 新建项目时的默认 4 列 (与服务端 DEFAULT_COLUMNS 一致) +const DEFAULT_COLUMNS = [ + { name: 'Plan', sortOrder: 0, boundStatus: 'todo' }, + { name: 'Doing', sortOrder: 1, boundStatus: 'doing' }, + { name: 'Action', sortOrder: 2, boundStatus: null }, + { name: 'Done', sortOrder: 3, boundStatus: 'done' }, +] +export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化'] +const MAX_NAME = 20 + +function normTaskKinds(v) { + if (!Array.isArray(v)) return null + const out = [] + for (const k of v) { + const s = String(k || '').trim().slice(0, 20) + if (s && !out.includes(s)) out.push(s) + if (out.length >= 30) break + } + return out +} + +// ---------- 查询助手 ---------- + +async function getColumns(projectId) { + return db().getAll( + 'SELECT * FROM BoardColumn WHERE projectId = ? ORDER BY sortOrder, id', + [String(projectId)], + ) +} + +async function getProjectMembers(projectId) { + const rows = await db().getAll( + `SELECT pm.userId, pm.permission, u.name, u.email + FROM ProjectMember pm LEFT JOIN User u ON u.id = pm.userId + WHERE pm.projectId = ? ORDER BY pm.createdAt, pm.id`, + [String(projectId)], + ) + return rows.map((r) => ({ userId: String(r.userId), name: r.name || String(r.userId), email: r.email || '', permission: r.permission || 'member' })) +} + +function memberMapOf(members) { + return new Map(members.map((m) => [m.userId, { id: m.userId, name: m.name }])) +} + +async function shapeTasks(rows, columns) { + const columnById = new Map(columns.map((c) => [String(c.id), c])) + const projectIds = [...new Set(rows.map((r) => String(r.projectId)))] + let membersByProject = new Map() + if (projectIds.length) { + const ph = projectIds.map(() => '?').join(',') + const pmRows = await db().getAll( + `SELECT pm.projectId, pm.userId, u.name FROM ProjectMember pm LEFT JOIN User u ON u.id = pm.userId WHERE pm.projectId IN (${ph})`, + projectIds, + ) + membersByProject = new Map(projectIds.map((pid) => [pid, new Map()])) + for (const r of pmRows) { + membersByProject.get(String(r.projectId)).set(String(r.userId), { id: String(r.userId), name: r.name || String(r.userId) }) + } + } + const taskIds = rows.map((r) => String(r.id)) + const memberRows = taskIds.length + ? await db().getAll( + `SELECT tm.taskId, tm.userId, u.name FROM TaskMember tm LEFT JOIN User u ON u.id = tm.userId WHERE tm.taskId IN (${taskIds.map(() => '?').join(',')})`, + taskIds, + ) + : [] + const tagRows = taskIds.length + ? await db().getAll(`SELECT taskId, name FROM TaskTag WHERE taskId IN (${taskIds.map(() => '?').join(',')})`, taskIds) + : [] + const membersByTask = new Map() + for (const r of memberRows) { + const k = String(r.taskId) + if (!membersByTask.has(k)) membersByTask.set(k, []) + membersByTask.get(k).push({ userId: String(r.userId), name: r.name || String(r.userId) }) + } + const tagsByTask = new Map() + for (const r of tagRows) { + const k = String(r.taskId) + if (!tagsByTask.has(k)) tagsByTask.set(k, []) + tagsByTask.get(k).push(r.name) + } + + const shapeOne = (t) => { + const members = membersByTask.get(String(t.id)) || [] + const usersByProject = membersByProject.get(String(t.projectId)) || new Map() + return { + ...t, + extras: parseJson(t.extras, []), + column: columnById.get(String(t.columnId)) || null, + assignee: t.assigneeId ? (usersByProject.get(String(t.assigneeId)) || { id: String(t.assigneeId), name: String(t.assigneeId) }) : null, + members, + tags: tagsByTask.get(String(t.id)) || [], + } + } + return shapeOne +} + +function projectSummary(p, memberCount, taskCount) { + return { + id: String(p.id), + uuid: p.uuid, + code: p.code || null, + name: p.name, + ownerId: String(p.ownerId), + isPersonal: !!p.isPersonal, + archived: !!p.archived, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + taskKinds: normTaskKinds(parseJson(p.taskKinds, null)) || DEFAULT_TASK_KINDS, + icon: p.icon || null, + memberCount, + taskCount, + } +} + +function assertUser() { + const uid = getCurrentUserId() + if (!uid) throw new Error('未登录') + return String(uid) +} + +// ---------- 项目 ---------- + export const projectApi = { - list: () => api('/projects'), - detail: (id) => api(`/projects/${id}`), - create: (body) => api('/projects', { method: 'POST', body }), - update: (id, body) => api(`/projects/${id}`, { method: 'PUT', body }), - remove: (id) => api(`/projects/${id}`, { method: 'DELETE' }), - addMember: (id, body) => api(`/projects/${id}/members`, { method: 'POST', body }), - removeMember: (id, uid) => api(`/projects/${id}/members/${uid}`, { method: 'DELETE' }), + // 我参与的项目 (本地库; 个人默认项目缺失且在线时回源一次让服务端补建) + list: async () => { + const uid = assertUser() + let rows = await db().getAll( + `SELECT p.* FROM Project p WHERE p.id IN (SELECT pm.projectId FROM ProjectMember pm WHERE pm.userId = ?) + ORDER BY p.archived, p.updatedAt DESC`, + [uid], + ) + if (!rows.some((r) => !!r.isPersonal && String(r.ownerId) === uid)) { + try { + await api('/projects') + await new Promise((r) => setTimeout(r, 1200)) + rows = await db().getAll( + `SELECT p.* FROM Project p WHERE p.id IN (SELECT pm.projectId FROM ProjectMember pm WHERE pm.userId = ?) + ORDER BY p.archived, p.updatedAt DESC`, + [uid], + ) + } catch { /* 离线: 无个人项目则跳过 */ } + } + const items = [] + for (const p of rows) { + const memberCount = await db().getOptional('SELECT COUNT(*) AS n FROM ProjectMember WHERE projectId = ?', [String(p.id)]) + const taskCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE projectId = ?', [String(p.id)]) + items.push(projectSummary(p, memberCount?.n || 0, taskCount?.n || 0)) + } + return { items } + }, + + detail: async (id) => { + const p = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(id)]) + if (!p) throw new Error('项目不存在或无权限') + const members = await getProjectMembers(p.id) + const columns = await getColumns(p.id) + const taskCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE projectId = ?', [String(p.id)]) + return { + item: { + ...projectSummary(p, members.length, taskCount?.n || 0), + members, + columns, + notes: parseJson(p.notes, []), + }, + } + }, + + create: async (body) => { + const uid = assertUser() + const name = String(body?.name || '').trim() + if (!name) throw new Error('项目名称不能为空') + if (name.length > MAX_NAME) throw new Error(`项目名称不能超过 ${MAX_NAME} 字`) + let code = null + const rawCode = body?.code + if (rawCode !== undefined && rawCode !== null && String(rawCode).trim() !== '') { + code = String(rawCode).trim().toUpperCase() + if (!/^[A-Z0-9]{2,8}$/.test(code)) throw new Error('项目编号须为 2~8 位字母数字') + const dup = await db().getOptional('SELECT id FROM Project WHERE code = ?', [code]) + if (dup) throw new Error(`项目编号 ${code} 已被占用`) + } + const id = uuid() + const ts = now() + await db().writeTransaction(async (tx) => { + await tx.execute( + 'INSERT INTO Project (id, uuid, code, name, ownerId, isPersonal, archived, notes, taskKinds, icon, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, id, code, name, uid, 0, 0, '[]', null, null, ts, ts], + ) + await tx.execute( + 'INSERT INTO ProjectMember (id, uuid, projectId, userId, permission, createdAt) VALUES (?, ?, ?, ?, ?, ?)', + [uuid(), uuid(), id, uid, 'member', ts], + ) + for (const c of DEFAULT_COLUMNS) { + await tx.execute( + 'INSERT INTO BoardColumn (id, uuid, projectId, name, sortOrder, boundStatus, retention, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [uuid(), uuid(), id, c.name, c.sortOrder, c.boundStatus, 'forever', ts, ts], + ) + } + }) + const p = await db().get('SELECT * FROM Project WHERE id = ?', [id]) + return { item: projectSummary(p, 1, 0) } + }, + + update: async (id, body) => { + await db().getOptional('SELECT id FROM Project WHERE id = ?', [String(id)]).then((p) => { + if (!p) throw new Error('项目不存在或无权限') + }) + const data = {} + if (body?.name !== undefined) { + const t = String(body.name).trim() + if (!t) throw new Error('项目名称不能为空') + if (t.length > MAX_NAME) throw new Error(`项目名称不能超过 ${MAX_NAME} 字`) + data.name = t + } + if (body?.code !== undefined) { + const c = String(body.code || '').trim().toUpperCase() + if (!/^[A-Z0-9]{2,8}$/.test(c)) throw new Error('项目编号须为 2~8 位字母数字') + const dup = await db().getOptional('SELECT id FROM Project WHERE code = ? AND id != ?', [c, String(id)]) + if (dup) throw new Error(`项目编号 ${c} 已被占用`) + data.code = c + } + if (body?.taskKinds !== undefined) { + const kinds = normTaskKinds(body.taskKinds) + if (!kinds?.length) throw new Error('至少保留一个任务类型') + data.taskKinds = JSON.stringify(kinds) + } + if (body?.icon !== undefined) { + if (body.icon === null) data.icon = null + else { + const s = String(body.icon) + if (!s.startsWith('data:image/')) throw new Error('图标格式非法') + if (s.length > 300_000) throw new Error('图标过大') + data.icon = s + } + } + if (body?.archived !== undefined) data.archived = body.archived ? 1 : 0 + if (body?.notes !== undefined) { + if (!Array.isArray(body.notes)) throw new Error('信息块格式非法') + data.notes = JSON.stringify(body.notes.slice(0, 50).map((n) => ({ + title: String(n?.title ?? '').slice(0, 50), + content: String(n?.content ?? '').slice(0, 20000), + }))) + } + const sets = Object.keys(data).map((k) => `"${k}" = ?`) + if (sets.length) { + await db().execute( + `UPDATE Project SET ${sets.join(', ')}, updatedAt = ? WHERE id = ?`, + [...Object.values(data), now(), String(id)], + ) + } + const p = await db().get('SELECT * FROM Project WHERE id = ?', [String(id)]) + const memberCount = await db().getOptional('SELECT COUNT(*) AS n FROM ProjectMember WHERE projectId = ?', [String(p.id)]) + const taskCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE projectId = ?', [String(p.id)]) + return { item: projectSummary(p, memberCount?.n || 0, taskCount?.n || 0) } + }, + + remove: async (id) => { + const p = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(id)]) + if (!p) throw new Error('项目不存在或无权限') + if (!!p.isPersonal) throw new Error('个人默认项目不可删除') + // 手动级联 (本地 SQLite 无外键级联) + await db().writeTransaction(async (tx) => { + const taskIds = (await tx.getAll('SELECT id FROM Task WHERE projectId = ?', [String(p.id)])).map((r) => String(r.id)) + if (taskIds.length) { + const ph = taskIds.map(() => '?').join(',') + await tx.execute(`DELETE FROM TaskMember WHERE taskId IN (${ph})`, taskIds) + await tx.execute(`DELETE FROM TaskTag WHERE taskId IN (${ph})`, taskIds) + } + await tx.execute('DELETE FROM Task WHERE projectId = ?', [String(p.id)]) + await tx.execute('DELETE FROM BoardColumn WHERE projectId = ?', [String(p.id)]) + await tx.execute('DELETE FROM ProjectMember WHERE projectId = ?', [String(p.id)]) + await tx.execute('DELETE FROM Project WHERE id = ?', [String(p.id)]) + }) + return { ok: true } + }, + + addMember: async (id, body) => { + const p = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(id)]) + if (!p) throw new Error('项目不存在或无权限') + if (!!p.isPersonal) throw new Error('个人默认项目不可添加成员') + let target = null + if (body?.userId) target = await db().getOptional('SELECT id, name FROM User WHERE id = ?', [String(body.userId)]) + else if (body?.email) target = await db().getOptional('SELECT id, name FROM User WHERE email = ?', [String(body.email).trim()]) + if (!target) throw new Error('用户不存在') + const dup = await db().getOptional('SELECT id FROM ProjectMember WHERE projectId = ? AND userId = ?', [String(p.id), String(target.id)]) + if (dup) throw new Error('该用户已是项目成员') + await db().execute( + 'INSERT INTO ProjectMember (id, uuid, projectId, userId, permission, createdAt) VALUES (?, ?, ?, ?, ?, ?)', + [uuid(), uuid(), String(p.id), String(target.id), 'member', now()], + ) + return { ok: true, member: { userId: String(target.id), name: target.name } } + }, + + removeMember: async (id, uid) => { + const p = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(id)]) + if (!p) throw new Error('项目不存在或无权限') + if (!!p.isPersonal) throw new Error('个人默认项目不可移除成员') + const me = getCurrentUserId() + if (String(uid) !== me && String(p.ownerId) !== me) throw new Error('只能移除自己,或由创建人移除他人') + await db().execute('DELETE FROM ProjectMember WHERE projectId = ? AND userId = ?', [String(p.id), String(uid)]) + return { ok: true } + }, } -// 看板列 +// ---------- 看板列 ---------- + export const columnApi = { - list: (projectId) => api(`/projects/${projectId}/columns`), - add: (projectId, body) => api(`/projects/${projectId}/columns`, { method: 'POST', body }), - update: (id, body) => api(`/columns/${id}`, { method: 'PUT', body }), - remove: (id, moveToColumnId) => api(`/columns/${id}${q({ moveToColumnId })}`, { method: 'DELETE' }), - order: (projectId, ids) => api(`/projects/${projectId}/columns/order`, { method: 'PUT', body: { ids } }), + list: async (projectId) => ({ items: await getColumns(projectId) }), + + add: async (projectId, body) => { + const name = String(body?.name || '').trim() + if (!name) throw new Error('列名不能为空') + if (name.length > MAX_NAME) throw new Error(`列名不能超过 ${MAX_NAME} 字`) + const cols = await getColumns(projectId) + if (cols.length >= 10) throw new Error('最多 10 列') + if (cols.some((c) => c.name === name)) throw new Error('列名已存在') + const id = uuid() + const ts = now() + await db().execute( + 'INSERT INTO BoardColumn (id, uuid, projectId, name, sortOrder, boundStatus, retention, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, id, String(projectId), name, cols.length ? Math.max(...cols.map((c) => Number(c.sortOrder))) + 1 : 0, null, 'forever', ts, ts], + ) + return { item: await db().get('SELECT * FROM BoardColumn WHERE id = ?', [id]) } + }, + + update: async (id, body) => { + const col = await db().getOptional('SELECT * FROM BoardColumn WHERE id = ?', [String(id)]) + if (!col) throw new Error('列不存在') + const data = {} + if (body?.name !== undefined) { + const t = String(body.name).trim() + if (!t) throw new Error('列名不能为空') + if (t.length > MAX_NAME) throw new Error(`列名不能超过 ${MAX_NAME} 字`) + const dup = await db().getOptional('SELECT id FROM BoardColumn WHERE projectId = ? AND name = ? AND id != ?', [String(col.projectId), t, String(id)]) + if (dup) throw new Error('列名已存在') + data.name = t + } + if (body?.boundStatus !== undefined) { + if (body.boundStatus !== null && !['todo', 'doing', 'done'].includes(body.boundStatus)) throw new Error('绑定状态取值非法') + data.boundStatus = body.boundStatus + } + if (body?.retention !== undefined) { + if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(body.retention)) throw new Error('保留选项取值非法') + data.retention = body.retention + } + const sets = Object.keys(data).map((k) => `"${k}" = ?`) + if (sets.length) { + await db().execute(`UPDATE BoardColumn SET ${sets.join(', ')}, updatedAt = ? WHERE id = ?`, [...Object.values(data), now(), String(id)]) + } + return { item: await db().get('SELECT * FROM BoardColumn WHERE id = ?', [String(id)]) } + }, + + remove: async (id, moveToColumnId) => { + const col = await db().getOptional('SELECT * FROM BoardColumn WHERE id = ?', [String(id)]) + if (!col) throw new Error('列不存在') + const cols = await getColumns(col.projectId) + if (cols.length <= 1) throw new Error('至少保留一列') + let targetId = String(moveToColumnId || '') + if (!targetId) { + const idx = cols.findIndex((c) => String(c.id) === String(id)) + targetId = String((cols[idx + 1] || cols[idx - 1]).id) + } + const target = cols.find((c) => String(c.id) === targetId) + if (!target || String(target.id) === String(id)) throw new Error('迁移目标列非法') + await db().writeTransaction(async (tx) => { + // 迁移任务: 状态按目标列绑定重新裁决 (与服务端同口径) + if (target.boundStatus) { + await tx.execute('UPDATE Task SET columnId = ?, status = ?, updatedAt = ? WHERE columnId = ?', [targetId, target.boundStatus, now(), String(id)]) + } else { + await tx.execute('UPDATE Task SET columnId = ?, updatedAt = ? WHERE columnId = ?', [targetId, now(), String(id)]) + } + await tx.execute('DELETE FROM BoardColumn WHERE id = ?', [String(id)]) + }) + return { ok: true, toColumnId: targetId } + }, + + order: async (projectId, ids) => { + const list = Array.isArray(ids) ? ids.map(String) : [] + if (!list.length) throw new Error('ids 不能为空') + await db().writeTransaction(async (tx) => { + for (let i = 0; i < list.length; i++) { + await tx.execute('UPDATE BoardColumn SET sortOrder = ?, updatedAt = ? WHERE id = ?', [i, now(), list[i]]) + } + }) + return { items: await getColumns(projectId) } + }, } -// 任务 +// ---------- 任务 ---------- + +function assertTitle(t) { + const s = String(t || '').trim() + if (!s) throw new Error('任务标题不能为空') + if (s.length > 30) throw new Error('任务标题不能超过 30 字') + return s +} + +function parseMarkDate(v) { + if (v === null || v === undefined || v === '') return null + const d = new Date(v) + if (Number.isNaN(d.getTime())) throw new Error('时间标记格式非法') + return d.toISOString() +} + export const taskApi = { - list: (projectId) => api(`/projects/${projectId}/tasks`), - create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }), - detail: (id) => api(`/tasks/${id}`), - update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }), - reorder: (projectId, ids) => api(`/projects/${projectId}/tasks/reorder`, { method: 'POST', body: { ids } }), - remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }), - copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }), + list: async (projectId) => { + const columns = await getColumns(projectId) + const rows = await db().getAll( + 'SELECT * FROM Task WHERE projectId = ? AND parentId IS NULL ORDER BY sortOrder, createdAt, id', + [String(projectId)], + ) + const shape = await shapeTasks(rows, columns) + const shaped = rows.map(shape) + // 子任务挂载 (与服务端 TASK_INCLUDE.children 同构) + for (const t of shaped) { + const childRows = await db().getAll( + 'SELECT * FROM Task WHERE parentId = ? ORDER BY sortOrder, id', + [String(t.id)], + ) + const childShape = await shapeTasks(childRows, columns) + t.children = childRows.map(childShape) + } + const visible = filterVisible(shaped, columns) + return { items: visible, columns, total: shaped.length } + }, + + create: async (projectId, body) => { + const uid = assertUser() + const title = assertTitle(body?.title) + const columns = await getColumns(projectId) + if (!columns.length) throw new Error('项目没有可用的看板列') + let parentId = null + if (body?.parentId) { + const parent = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(body.parentId)]) + if (!parent) throw new Error('父任务不存在') + if (String(parent.projectId) !== String(projectId)) throw new Error('子任务必须与父任务同项目') + if (parent.parentId) throw new Error('仅支持一级子任务') + parentId = String(parent.id) + } + const memberIds = await db().getAll('SELECT userId FROM ProjectMember WHERE projectId = ?', [String(projectId)]) + const memberSet = new Set(memberIds.map((r) => String(r.userId))) + let finalAssignee = null + if (body?.assigneeId === undefined) finalAssignee = memberSet.has(uid) ? uid : null + else if (body?.assigneeId !== null) { + const aid = String(body.assigneeId) + if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员') + finalAssignee = aid + } + const firstCol = columns[0] + const id = uuid() + const ts = now() + await db().writeTransaction(async (tx) => { + await tx.execute( + `INSERT INTO Task (id, uuid, projectId, columnId, parentId, title, description, kind, markDate, status, doneAt, assigneeId, creatorId, extras, sortOrder, number, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, id, String(projectId), String(firstCol.id), parentId, title, String(body?.description || ''), + body?.kind ? String(body.kind).trim().slice(0, 20) : null, parseMarkDate(body?.markDate), + firstCol.boundStatus || 'todo', null, finalAssignee, uid, '[]', 0, null, ts, ts], + ) + for (const mid of (Array.isArray(body?.memberIds) ? body.memberIds : []).map(String).filter((x) => memberSet.has(x))) { + await tx.execute('INSERT INTO TaskMember (id, uuid, taskId, userId) VALUES (?, ?, ?, ?)', [uuid(), uuid(), id, mid]) + } + for (const name of (Array.isArray(body?.tags) ? body.tags : []).filter(Boolean).map((n) => String(n).slice(0, 20))) { + await tx.execute('INSERT INTO TaskTag (id, uuid, taskId, name) VALUES (?, ?, ?, ?)', [uuid(), uuid(), id, name]) + } + }) + const row = await db().get('SELECT * FROM Task WHERE id = ?', [id]) + const shape = await shapeTasks([row], columns) + return { item: shape(row) } + }, + + detail: async (id) => { + const row = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)]) + if (!row) throw new Error('任务不存在或无权限') + const columns = await getColumns(row.projectId) + const shape = await shapeTasks([row], columns) + return { item: shape(row) } + }, + + update: async (id, body) => { + const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)]) + if (!task) throw new Error('任务不存在或无权限') + const columns = await getColumns(task.projectId) + const data = {} + + if (body?.title !== undefined) data.title = assertTitle(body.title) + if (body?.description !== undefined) data.description = String(body.description) + if (body?.kind !== undefined) data.kind = body.kind ? String(body.kind).trim().slice(0, 20) : null + if (body?.markDate !== undefined) data.markDate = parseMarkDate(body.markDate) + if (body?.extras !== undefined) { + if (!Array.isArray(body.extras)) throw new Error('扩展信息格式非法') + data.extras = JSON.stringify(body.extras.map((e) => ({ + label: String(e?.label ?? '').slice(0, 40), + value: String(e?.value ?? ''), + url: e?.url ? String(e.url) : '', + }))) + } + if (body?.sortOrder !== undefined) data.sortOrder = Number(body.sortOrder) || 0 + + const memberRows = await db().getAll('SELECT userId FROM ProjectMember WHERE projectId = ?', [String(task.projectId)]) + const memberSet = new Set(memberRows.map((r) => String(r.userId))) + if (body?.assigneeId !== undefined) { + if (body.assigneeId === null || body.assigneeId === '') data.assigneeId = null + else { + const aid = String(body.assigneeId) + if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员') + data.assigneeId = aid + } + } + + // 列 / 状态联动裁决 (与 server/task-domain/resolveStatus.js 同口径) + let status = task.status + let columnId = String(task.columnId) + const patch = {} + if (body?.columnId !== undefined) patch.columnId = String(body.columnId) + if (body?.status !== undefined) { + if (!['todo', 'doing', 'done'].includes(body.status)) throw new Error('任务状态取值非法') + patch.status = body.status + } + if (patch.columnId !== undefined && !columns.some((c) => String(c.id) === patch.columnId)) { + throw new Error('看板列不存在') + } + if (Object.keys(patch).length) { + status = patch.status !== undefined ? patch.status : task.status + columnId = patch.columnId !== undefined ? patch.columnId : String(task.columnId) + if (!task.parentId) { + const ordered = [...columns].sort((a, b) => (Number(a.sortOrder) - Number(b.sortOrder)) || String(a.id).localeCompare(String(b.id))) + const targetColumn = ordered.find((c) => String(c.id) === columnId) + if (patch.columnId !== undefined && targetColumn) { + if (targetColumn.boundStatus) status = targetColumn.boundStatus + } + if (patch.status !== undefined) { + if (!(targetColumn && targetColumn.boundStatus === status)) { + const hit = ordered.find((c) => c.boundStatus === status) + if (hit) columnId = String(hit.id) + } + } + } + data.status = status + data.columnId = columnId + } + if (data.status === 'done' && task.status !== 'done') data.doneAt = now() + else if (data.status !== undefined && data.status !== 'done') data.doneAt = null + + // 拖拽降为子任务 + if (body?.parentId !== undefined) { + if (body.parentId === null) { + data.parentId = null + } else { + const pid = String(body.parentId) + if (pid === String(task.id)) throw new Error('不能成为自己的子任务') + const parent = await db().getOptional('SELECT * FROM Task WHERE id = ?', [pid]) + if (!parent) throw new Error('目标任务不存在') + if (String(parent.projectId) !== String(task.projectId)) throw new Error('子任务必须与父任务同项目') + if (parent.parentId) throw new Error('仅支持一级子任务') + const childCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE parentId = ?', [String(task.id)]) + if ((childCount?.n || 0) > 0) throw new Error('该任务已有子任务,不能成为子任务') + data.parentId = pid + } + } + + await db().writeTransaction(async (tx) => { + const sets = Object.keys(data).map((k) => `"${k}" = ?`) + if (sets.length) { + await tx.execute(`UPDATE Task SET ${sets.join(', ')}, updatedAt = ? WHERE id = ?`, [...Object.values(data), now(), String(task.id)]) + } + if (Array.isArray(body?.memberIds)) { + const ids = body.memberIds.map(String).filter((x) => memberSet.has(x)) + await tx.execute('DELETE FROM TaskMember WHERE taskId = ?', [String(task.id)]) + for (const mid of ids) { + await tx.execute('INSERT INTO TaskMember (id, uuid, taskId, userId) VALUES (?, ?, ?, ?)', [uuid(), uuid(), String(task.id), mid]) + } + } + if (Array.isArray(body?.tags)) { + await tx.execute('DELETE FROM TaskTag WHERE taskId = ?', [String(task.id)]) + const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10) + for (const name of names) { + await tx.execute('INSERT INTO TaskTag (id, uuid, taskId, name) VALUES (?, ?, ?, ?)', [uuid(), uuid(), String(task.id), name.slice(0, 20)]) + } + } + }) + const row = await db().get('SELECT * FROM Task WHERE id = ?', [String(task.id)]) + const shape = await shapeTasks([row], columns) + return { item: shape(row) } + }, + + reorder: async (projectId, ids) => { + const list = Array.isArray(ids) ? ids.map(String).filter(Boolean) : [] + if (!list.length) throw new Error('ids 不能为空') + await db().writeTransaction(async (tx) => { + for (let i = 0; i < list.length; i++) { + await tx.execute('UPDATE Task SET sortOrder = ?, updatedAt = ? WHERE id = ?', [i, now(), list[i]]) + } + }) + return { ok: true } + }, + + remove: async (id) => { + const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)]) + if (!task) throw new Error('任务不存在或无权限') + const childCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE parentId = ?', [String(task.id)]) + await db().writeTransaction(async (tx) => { + const ids = [String(task.id), ...((await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])).map((r) => String(r.id)))] + const ph = ids.map(() => '?').join(',') + await tx.execute(`DELETE FROM TaskMember WHERE taskId IN (${ph})`, ids) + await tx.execute(`DELETE FROM TaskTag WHERE taskId IN (${ph})`, ids) + await tx.execute(`DELETE FROM Task WHERE id IN (${ph})`, ids) + }) + return { ok: true, deletedSubtasks: childCount?.n || 0 } + }, + + copy: async (id, projectId) => { + const uid = assertUser() + const source = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)]) + if (!source) throw new Error('任务不存在或无权限') + const targetProject = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(projectId)]) + if (!targetProject) throw new Error('目标项目不存在或你不是其成员') + const targetColumns = await getColumns(targetProject.id) + if (!targetColumns.length) throw new Error('目标项目没有可用的看板列') + const sourceColumns = await getColumns(source.projectId) + const targetMemberIds = new Set((await db().getAll('SELECT userId FROM ProjectMember WHERE projectId = ?', [String(targetProject.id)])).map((r) => String(r.userId))) + + const pickColumn = (sourceColumnId) => { + const src = sourceColumns.find((c) => String(c.id) === String(sourceColumnId)) + if (src) { + const same = targetColumns.find((c) => c.name === src.name) + if (same) return String(same.id) + } + return String(targetColumns[0]?.id) + } + + const copyOne = async (tx, s, parentId) => { + const newId = uuid() + const ts = now() + await tx.execute( + `INSERT INTO Task (id, uuid, projectId, columnId, parentId, title, description, kind, markDate, status, doneAt, assigneeId, creatorId, extras, sortOrder, number, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [newId, newId, String(targetProject.id), pickColumn(s.columnId), parentId, s.title, s.description || '', s.kind || null, + s.markDate || null, s.status, s.status === 'done' ? (s.doneAt || ts) : null, + s.assigneeId && targetMemberIds.has(String(s.assigneeId)) ? String(s.assigneeId) : null, + uid, s.extras || '[]', Number(s.sortOrder) || 0, null, ts, ts], + ) + const tagRows = await tx.getAll('SELECT name FROM TaskTag WHERE taskId = ?', [String(s.id)]) + for (const t of tagRows) { + await tx.execute('INSERT INTO TaskTag (id, uuid, taskId, name) VALUES (?, ?, ?, ?)', [uuid(), uuid(), newId, t.name]) + } + const keptMembers = (await tx.getAll('SELECT userId FROM TaskMember WHERE taskId = ?', [String(s.id)])).map((r) => String(r.userId)).filter((x) => targetMemberIds.has(x)) + for (const m of keptMembers) { + await tx.execute('INSERT INTO TaskMember (id, uuid, taskId, userId) VALUES (?, ?, ?, ?)', [uuid(), uuid(), newId, m]) + } + return newId + } + + const newId = await db().writeTransaction(async (tx) => { + const pid = await copyOne(tx, source, null) + const children = await tx.getAll('SELECT * FROM Task WHERE parentId = ?', [String(source.id)]) + for (const child of children) await copyOne(tx, child, pid) + return pid + }) + const row = await db().get('SELECT * FROM Task WHERE id = ?', [newId]) + const shape = await shapeTasks([row], targetColumns) + return { item: shape(row), dropped: { assignee: null, members: [] } } + }, } diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 1aeb886..06c8a7d 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useState } from 'react' -import { api, getToken, setToken, setRememberFlag } from '../api/client.js' +import { api, getToken, setToken, setRememberFlag, setCurrentUserId } from '../api/client.js' +import { disconnectAndClearPowersync } from '../lib/powersync/db.js' const AuthContext = createContext(null) @@ -17,10 +18,14 @@ export function AuthProvider({ children }) { } try { const data = await api('/auth/me') - if (!cancelled) setUser(data.user) + if (!cancelled) { + setUser(data.user) + setCurrentUserId(data.user?.id) + } } catch { if (!cancelled) { setToken('') + setCurrentUserId('') setUser(null) } } finally { @@ -36,6 +41,7 @@ export function AuthProvider({ children }) { setToken(data.token) setRememberFlag(!!remember) setUser(data.user) + setCurrentUserId(data.user?.id) return data.user } @@ -44,6 +50,9 @@ export function AuthProvider({ children }) { setToken('') setRememberFlag(false) setUser(null) + setCurrentUserId('') + // 断开同步并清空本地 SQLite (0.2.1 离线编辑) + try { await disconnectAndClearPowersync() } catch { /* ignore */ } } return ( diff --git a/src/components/task/TaskCard.jsx b/src/components/task/TaskCard.jsx index 889e02b..9f3a781 100644 --- a/src/components/task/TaskCard.jsx +++ b/src/components/task/TaskCard.jsx @@ -9,7 +9,7 @@ import { kindColor } from '../../pages/task/kind.js' const MAX_SUB = 3 export default function TaskCard({ - task, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false, + task, codePrefix, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false, }) { const children = task.children || [] const shown = children.slice(0, MAX_SUB) @@ -47,6 +47,9 @@ export default function TaskCard({
+ {codePrefix && task.number != null && ( + {codePrefix}-{task.number} + )} {task.markDate && ( {task.markDate.slice(5, 10).replace('-', '/')} )} diff --git a/src/components/task/TaskRow.jsx b/src/components/task/TaskRow.jsx index 3c342a1..9bddf99 100644 --- a/src/components/task/TaskRow.jsx +++ b/src/components/task/TaskRow.jsx @@ -10,7 +10,7 @@ const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中 // 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动, // 避免长按行后抬手时合成的 click 落在控件上误触状态切换 export default function TaskRow({ - task, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragging = false, dragOver, indent = false, children, + task, codePrefix, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragging = false, dragOver, indent = false, children, }) { const done = task.status === 'done' return ( @@ -52,6 +52,9 @@ export default function TaskRow({
{task.title}
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
+ {codePrefix && task.number != null && ( + {codePrefix}-{task.number} + )} {task.markDate && ( {task.markDate.slice(5, 10).replace('-', '/')} )} diff --git a/src/lib/powersync/connector.js b/src/lib/powersync/connector.js index 134f3db..dbb0b1f 100644 --- a/src/lib/powersync/connector.js +++ b/src/lib/powersync/connector.js @@ -1,9 +1,9 @@ // PowerSync 后端连接器: 凭证获取 + 上传队列落地 (0.2.1) // 契约见 server/routes/powersync.js: POST /api/powersync/upload { ops: [{ id, table, op, uuid, data }] } -import { PowerSyncBackendConnector, UpdateType } from '@powersync/web' +// Web SDK 无需基类, 实现 fetchCredentials / uploadData 两个方法即可 import { api, getToken } from '../../api/client.js' -export class IBoardConnector extends PowerSyncBackendConnector { +export class IBoardConnector { async fetchCredentials() { // 未登录: 返回 null 让 SDK 保持断开 (登录后由页面触发 connect) if (!getToken()) return null @@ -19,14 +19,26 @@ export class IBoardConnector extends PowerSyncBackendConnector { const batch = await database.getCrudBatch() if (!batch) return - // 本地行的 id 即为 uuid (客户端创建时生成), 服务端按 uuid 幂等 upsert - const ops = batch.crud.map((op, i) => ({ - id: String(i), - table: op.table, - op: op.op === UpdateType.DELETE ? 'DELETE' : 'PUT', - uuid: op.id, - data: op.opData || {}, - })) + // 本地行的 id 即为 uuid (客户端创建时生成), 服务端按 uuid 幂等 upsert。 + // PATCH 的 opData 只含变更列, 这里统一回读全行按 PUT 上行, 保证服务端整行 upsert 语义。 + const ops = [] + for (let i = 0; i < batch.crud.length; i++) { + const op = batch.crud[i] + let data = op.opData || {} + if (op.op !== 'DELETE') { + try { + const row = await database.get(`SELECT * FROM "${op.table}" WHERE id = ?`, [op.id]) + if (row) data = row + } catch { /* 回读失败则用 opData 兜底 */ } + } + ops.push({ + id: String(i), + table: op.table, + op: op.op === 'DELETE' ? 'DELETE' : 'PUT', + uuid: op.id, + data, + }) + } let res try { diff --git a/src/lib/powersync/react.jsx b/src/lib/powersync/react.jsx new file mode 100644 index 0000000..a8bb715 --- /dev/null +++ b/src/lib/powersync/react.jsx @@ -0,0 +1,26 @@ +// PowerSync React 接入 (0.2.1 离线编辑) +// 用法: 在 App 外层包 ; 任务模块内用 re-export 的 useQuery/useStatus/usePowerSync +import React, { useEffect, useMemo } from 'react' +import { PowerSyncContext } from '@powersync/react' +import { getPowersyncDb, connectPowersync, disconnectAndClearPowersync } from './db.js' +import { useAuth } from '../../auth/AuthContext.jsx' + +/** + * 登录后建立同步连接; 登出后断开并清空本地库 (避免下一用户读到残留数据) + */ +export function PowerSyncProvider({ children }) { + const { user } = useAuth() + const db = useMemo(() => getPowersyncDb(), []) + + useEffect(() => { + if (user) { + connectPowersync().catch((e) => console.error('[powersync] connect 失败:', e)) + } else { + disconnectAndClearPowersync() + } + }, [user]) + + return {children} +} + +export { useQuery, useStatus, useSuspenseQuery, usePowerSync } from '@powersync/react' diff --git a/src/pages/task/BoardView.jsx b/src/pages/task/BoardView.jsx index a434c7b..8cc932d 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, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd, + tasks, columns, codePrefix, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd, }) { const drag = useColumnDrag({ onDrop: onMoveColumn }) @@ -69,6 +69,7 @@ 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 326abff..c93600c 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, onToggle, onOpen, onMore, onMoveColumn, onReorder, + tasks, columns, codePrefix, onToggle, onOpen, onMore, onMoveColumn, onReorder, }) { const [collapsed, setCollapsed] = useState({}) const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder }) @@ -49,6 +49,7 @@ 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 9558e8e..0399984 100644 --- a/src/pages/task/ProjectView.jsx +++ b/src/pages/task/ProjectView.jsx @@ -259,6 +259,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit setOpenTask(t)} onMore={openTaskMenu} @@ -269,6 +270,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit setOpenTask(t)} onToggleSub={(c) => toggleDone(c)} diff --git a/src/styles/globals.css b/src/styles/globals.css index 7d0889a..73760f4 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -718,6 +718,13 @@ body.me-safe-top .app-safe-top { height: 0; } font-size: 10px; line-height: 1; font-weight: 600; color: #fff; border-radius: 4px; padding: 3px 5px; flex-shrink: 0; white-space: nowrap; } +/* 任务编号 (0.2.1): `项目编号-序号`, 服务端分配 */ +.num-tag { + display: inline-flex; align-items: center; + font-size: 10px; line-height: 1; font-weight: 600; + color: #5b6b7c; background: #eef2f6; border-radius: 4px; padding: 3px 5px; + flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums; +} /* 项目管理: 任务类型编辑 chips */ .kind-chip-row { display: flex; flex-wrap: wrap; gap: 8px; } .kind-chip { diff --git a/vite.config.js b/vite.config.js index 46ec1e0..e95b6c0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,6 +17,10 @@ const BUILD_ID = makeBuildId() // 注意: 不能设 hmr: false —— 那会连 WS 通道一起关掉, 文件变更不再触发任何刷新。 export default defineConfig({ plugins: [react()], + // PowerSync 的数据库/同步跑在 module worker 里, iife worker 不支持代码分包 (0.2.1) + worker: { + format: 'es', + }, define: { __BUILD_ID__: JSON.stringify(BUILD_ID), // 构建时生成的唯一 build 号 },