Merge branch 'dev-0.2.1' into dev-0.2

# Conflicts:
#	generated/prisma/internal/class.ts
#	generated/prisma/internal/prismaNamespace.ts
#	generated/prisma/internal/prismaNamespaceBrowser.ts
#	generated/prisma/models/BoardColumn.ts
#	generated/prisma/models/Project.ts
#	generated/prisma/models/ProjectMember.ts
#	generated/prisma/models/Task.ts
#	generated/prisma/models/TaskMember.ts
#	generated/prisma/models/TaskTag.ts
#	generated/prisma/models/User.ts
#	server/routes/projects.js
#	server/routes/tasks.js
#	src/api/task.js
This commit is contained in:
jif2.zhang
2026-10-26 20:46:25 +08:00
95 changed files with 8694 additions and 1609 deletions
+5 -2
View File
@@ -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 (
<Routes>
<PowerSyncProvider>
<Routes>
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
<Route element={<RequireAuth><MobileShell /></RequireAuth>}>
{/* 打卡为默认首页 */}
@@ -49,6 +51,7 @@ export default function App() {
<Route path="/me" element={<MePage />} />
</Route>
<Route path="*" element={<Navigate to={HOME_PATH} replace />} />
</Routes>
</Routes>
</PowerSyncProvider>
)
}
+144 -11
View File
@@ -1,13 +1,121 @@
// API 调用封装: 自动带 JWT、401 自动清 token、抛统一错误
// 0.2.1 离线支持:
// - GET 请求成功后把响应写入本地缓存 (localStorage), 离线/网络失败时回退缓存 (界面仍有数据)
// - 写请求 (POST/PUT/DELETE) 仍要求在线 (任务模块的离线写走 PowerSync 上传队列)
// - 统一在线/离线状态订阅, 供 UI 显示离线标识
const TOKEN_KEY = 'iboard_token'
const REMEMBER_KEY = 'iboard_remember'
const UID_KEY = 'iboard_uid'
// API 基址:
// - 默认相对路径 `/api` (web dev: Vite 代理; web prod: 同源 Express 一起托管)
// - 打包 Android 时设 VITE_API_BASE=http://bh.vps.honor3.com:3001 走外网后端
// - 打包 Android / Windows 外壳时设 VITE_API_BASE=http://bh.vps.honor3.com:3001 走外网后端
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/+$/, '')
const API_PREFIX = API_BASE ? API_BASE + '/api' : '/api'
// ---------- 在线/离线状态 (网络事件 + 请求结果联动) ----------
let offline = typeof navigator !== 'undefined' ? navigator.onLine === false : false
const offlineListeners = new Set()
export function isOffline() {
return offline
}
function setOffline(next) {
if (offline === next) return
offline = next
offlineListeners.forEach((cb) => {
try { cb(next) } catch { /* ignore */ }
})
}
if (typeof window !== 'undefined') {
window.addEventListener('online', () => setOffline(false))
window.addEventListener('offline', () => setOffline(true))
}
/** 订阅在线/离线变化, 返回取消函数 */
export function subscribeOffline(cb) {
offlineListeners.add(cb)
return () => offlineListeners.delete(cb)
}
// ---------- GET 响应本地缓存 ----------
// 目的: 任何界面只要成功访问过一次, 离线时都能展示上次的数据
const CACHE_PREFIX = 'iboard_cache:'
const CACHE_TOTAL_LIMIT = 3 * 1024 * 1024 // 约 3MB (localStorage 通常 5MB)
const CACHE_ENTRY_LIMIT = 512 * 1024 // 单条上限, 超大响应不缓存
function cacheKey(path) {
return CACHE_PREFIX + path
}
function readCache(path) {
try {
const raw = localStorage.getItem(cacheKey(path))
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || !('data' in parsed)) return null
return parsed
} catch {
return null
}
}
function cacheSizeUsed() {
let total = 0
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k && k.startsWith(CACHE_PREFIX)) total += (localStorage.getItem(k) || '').length
}
} catch { /* ignore */ }
return total
}
function evictOldest(needBytes) {
try {
const entries = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (!k || !k.startsWith(CACHE_PREFIX)) continue
let ts = 0
try { ts = JSON.parse(localStorage.getItem(k) || '{}').ts || 0 } catch { /* ignore */ }
entries.push({ k, ts })
}
entries.sort((a, b) => a.ts - b.ts)
let used = cacheSizeUsed()
for (const e of entries) {
if (used + needBytes <= CACHE_TOTAL_LIMIT) break
used -= (localStorage.getItem(e.k) || '').length
localStorage.removeItem(e.k)
}
} catch { /* ignore */ }
}
function writeCache(path, data) {
try {
const payload = JSON.stringify({ ts: Date.now(), data })
if (payload.length > CACHE_ENTRY_LIMIT) return // 过大不缓存
evictOldest(payload.length)
localStorage.setItem(cacheKey(path), payload)
} catch {
// 配额满等异常: 静默失败, 不影响主流程
}
}
/** 清空所有 GET 缓存 (退出登录时调用) */
export function clearApiCache() {
try {
const keys = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k && k.startsWith(CACHE_PREFIX)) keys.push(k)
}
keys.forEach((k) => localStorage.removeItem(k))
} catch { /* ignore */ }
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
@@ -26,21 +134,45 @@ export function setRememberFlag(v) {
else localStorage.removeItem(REMEMBER_KEY)
}
// 当前用户 id (0.2.1 离线写入需要 ownerId/creatorId, 本地库无法即时从服务端获取)
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
}
export async function api(path, { method = 'GET', body, headers = {} } = {}) {
const token = getToken()
const res = await fetch(API_PREFIX + path, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
})
const isRead = method === 'GET'
let res
try {
res = await fetch(API_PREFIX + path, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
})
} catch {
// 网络层失败: 离线 / 服务不可达
setOffline(true)
if (isRead) {
const cached = readCache(path)
if (cached) return cached.data
throw new Error('当前离线,且该界面尚无本地缓存')
}
throw new Error('当前离线,修改操作需要联网后重试')
}
setOffline(false)
if (res.status === 401) {
setToken('')
const data = await res.json().catch(() => ({}))
@@ -49,5 +181,6 @@ export async function api(path, { method = 'GET', body, headers = {} } = {}) {
}
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || '请求失败 (' + res.status + ')')
if (isRead) writeCache(path, data)
return data
}
}
+775 -44
View File
@@ -1,44 +1,775 @@
import { api } from './client.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 : ''
}
// 项目
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' }),
}
// 看板列
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 } }),
}
// 任务
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 } }),
archive: (id) => api(`/tasks/${id}/archive`, { method: 'POST', body: {} }),
unarchive: (id) => api(`/tasks/${id}/unarchive`, { method: 'POST', body: {} }),
archivedList: (projectId, params) => api(`/projects/${projectId}/tasks/archived` + q(params)),
}
// 任务模块数据层 (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 db = () => getPowersyncDb()
// crypto.randomUUID 仅在安全上下文 (https / localhost) 可用; 部署站为 http 明文, 用 getRandomValues 兜底生成 v4
const uuid = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
const b = crypto.getRandomValues(new Uint8Array(16))
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
}
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: 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: 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: async (projectId) => {
const columns = await getColumns(projectId)
const rows = await db().getAll(
'SELECT * FROM Task WHERE projectId = ? AND parentId IS NULL AND archived = 0 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, archived, archivedAt, archiveSource, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)`,
[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: [] } }
},
// ---------- 归档 (T-ARC, 本地实现: 写本地库 -> 上行) ----------
/** 手动归档: 连带全部子任务, 幂等 (T-ARC-01) */
archive: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限')
const now = now()
await db().writeTransaction(async (tx) => {
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
const ph = ids.map(() => '?').join(',')
await tx.execute(
`UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`,
[now, now, ...ids],
)
})
return { ok: true, count: 1 }
},
/** 恢复归档: 回原列, 状态不变, 连带子任务 (T-ARC-03) */
unarchive: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限')
const now = now()
await db().writeTransaction(async (tx) => {
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
const ph = ids.map(() => '?').join(',')
await tx.execute(
`UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`,
[now, ...ids],
)
})
return { ok: true, count: 1 }
},
/**
* 已归档查询: 关键词 / 类型 / 列 / 归档日期区间 + 游标分页 (T-ARC-04)
* 参数: { keyword, kind, columnId, from, to, limit, cursor }
* cursor 形如 "<archivedAt ISO>|<id>", 按 (archivedAt desc, id desc) 取下一页
*/
archivedList: async (projectId, params = {}) => {
const columns = await getColumns(projectId)
const p = params || {}
const limit = Math.min(100, Math.max(1, Number(p.limit) || 20))
const where = ['projectId = ?', 'archived = 1', 'parentId IS NULL']
const args = [String(projectId)]
if (p.keyword) {
where.push('title LIKE ?')
args.push(`%${String(p.keyword).trim()}%`)
}
if (p.kind) {
where.push('kind = ?')
args.push(String(p.kind))
}
if (p.columnId) {
where.push('columnId = ?')
args.push(String(p.columnId))
}
if (p.from) {
where.push('archivedAt >= ?')
args.push(String(p.from) + 'T00:00:00+08:00'.replace('+08:00', 'Z'))
}
if (p.to) {
where.push('archivedAt <= ?')
args.push(String(p.to) + 'T23:59:59.999Z')
}
if (p.cursor) {
const [ts, cid] = String(p.cursor).split('|')
where.push('(archivedAt < ? OR (archivedAt = ? AND id < ?))')
args.push(ts, ts, String(cid))
}
const rows = await db().getAll(
`SELECT * FROM Task WHERE ${where.join(' AND ')} ORDER BY archivedAt DESC, id DESC LIMIT ${limit + 1}`,
args,
)
const hasMore = rows.length > limit
const page = rows.slice(0, limit)
const shape = await shapeTasks(page, columns)
const items = page.map(shape)
const last = page[page.length - 1]
const nextCursor = hasMore && last ? `${last.archivedAt}|${last.id}` : null
return { items, nextCursor }
},
}
+13 -2
View File
@@ -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, clearApiCache } 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,11 @@ export function AuthProvider({ children }) {
setToken('')
setRememberFlag(false)
setUser(null)
setCurrentUserId('')
try { clearApiCache() } catch { /* ignore */ }
try { localStorage.removeItem('iboard_ps_cred') } catch { /* ignore */ }
// 断开同步并清空本地 SQLite (0.2.1 离线编辑)
try { await disconnectAndClearPowersync() } catch { /* ignore */ }
}
return (
+4 -1
View File
@@ -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({
</div>
<div className="bmeta">
{codePrefix && task.number != null && (
<span className="num-tag">{codePrefix}-{task.number}</span>
)}
{task.markDate && (
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
)}
+4 -1
View File
@@ -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({
<div className="task-title">{task.title}</div>
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
<div className="task-meta">
{codePrefix && task.number != null && (
<span className="num-tag">{codePrefix}-{task.number}</span>
)}
{task.markDate && (
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
)}
+12
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import BottomNav from '../components/BottomNav.jsx'
import { isOffline, subscribeOffline } from '../api/client.js'
// 移动端应用外壳
// - 顶部安全区: 用 --safe-top 撑开 (由 main.jsx 的 calibrateSafeTop 实测校准)
// - 中间 <main>: flex:1 可滚动主区
// - 底部 TabBar: antd-mobile TabBar 自带底部安全区
// - 左上角离线标识 (0.2.1): 离线或服务不可达时显示, 请求恢复后自动隐藏
//
// 注意: 这里不再用 antd-mobile 的 <SafeArea position="top" />.
// 它只是一个空 div, 高度完全依赖 env(safe-area-inset-top); 而 Android WebView
@@ -22,9 +24,19 @@ export default function MobileShell() {
return () => clearTimeout(t)
}, [location.pathname])
// 离线状态 (窗口左上角标识)
const [offline, setOfflineState] = useState(isOffline())
useEffect(() => subscribeOffline(setOfflineState), [])
return (
<div className="app-root">
<div className="app-safe-top" aria-hidden="true" />
{offline && (
<div className="offline-badge" title="当前离线:显示本地缓存数据">
<span className="offline-dot" />
<span className="offline-text">离线</span>
</div>
)}
<main className="app-main">
<div className={'page-transition' + (animating ? ' active' : '')}>
<Outlet />
+74
View File
@@ -0,0 +1,74 @@
// PowerSync 后端连接器: 凭证获取 + 上传队列落地 (0.2.1)
// 契约见 server/routes/powersync.js: POST /api/powersync/upload { ops: [{ id, table, op, uuid, data }] }
// Web SDK 无需基类, 实现 fetchCredentials / uploadData 两个方法即可
import { api, getToken } from '../../api/client.js'
// 上次成功的同步凭证: 离线时复用, 避免 SDK 因"凭证不可用"清空本地库
const CRED_KEY = 'iboard_ps_cred'
export class IBoardConnector {
async fetchCredentials() {
// 未登录: 返回 null 让 SDK 保持断开 (登录后由页面触发 connect)
if (!getToken()) return null
try {
const { token, endpoint } = await api('/powersync/token', { method: 'POST' })
// VITE_POWERSYNC_URL: 打包端 (Tauri 外壳等) 可覆盖服务端下发的同步地址
const ep = import.meta.env.VITE_POWERSYNC_URL || endpoint || ''
if (ep && token) {
try { localStorage.setItem(CRED_KEY, JSON.stringify({ endpoint: ep, token, ts: Date.now() })) } catch { /* ignore */ }
}
return { endpoint: ep, token }
} catch {
// 离线 / 服务不可达: 复用上次凭证 (本地库数据继续可读, 联网后自动恢复同步)
try {
const cached = JSON.parse(localStorage.getItem(CRED_KEY) || 'null')
if (cached?.endpoint && cached?.token) return { endpoint: cached.endpoint, token: cached.token }
} catch { /* ignore */ }
return null
}
}
async invalidateCredentials() {
// 交给 SDK: 下次 fetchCredentials 会重新尝试取新凭证 (离线时回退缓存)
}
async uploadData(database) {
const batch = await database.getCrudBatch()
if (!batch) return
// 本地行的 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 {
res = await api('/powersync/upload', { method: 'POST', body: { ops } })
} catch (e) {
// 网络失败/401: 抛出让 SDK 稍后重试本批
throw e
}
const failed = (res?.results || []).filter((r) => !r.ok)
if (failed.length) {
// 单条业务错误: 记录并放行 (重试也无法成功, 避免队列卡死); 服务端已尽力落地其余 op
console.error('[powersync] upload 部分失败:', failed)
}
await batch.complete()
}
}
+37
View File
@@ -0,0 +1,37 @@
// PowerSync 本地库单例 (0.2.1 离线编辑)
// 页面用法: usePowerSyncQuery 等见 src/lib/powersync/react.jsx; 写入用 db.execute/writeTransaction
import { PowerSyncDatabase } from '@powersync/web'
import { AppSchema } from './schema.js'
import { IBoardConnector } from './connector.js'
let dbInstance = null
export function getPowersyncDb() {
if (!dbInstance) {
dbInstance = new PowerSyncDatabase({
schema: AppSchema,
database: {
// 默认 IDBBatchAtomicVFS (IndexedDB), 兼容性最广; WebView2 (Chromium) 后续可切 OPFS 加速
dbFilename: 'iboard-powersync.db',
},
})
}
return dbInstance
}
/** 登录后调用: 建立同步连接 (幂等) */
export async function connectPowersync() {
const db = getPowersyncDb()
db.connect(new IBoardConnector())
return db
}
/** 退出登录时调用: 断开同步并清空本地库 */
export async function disconnectAndClearPowersync() {
if (!dbInstance) return
try {
await dbInstance.disconnectAndClear()
} catch {
/* ignore */
}
}
+26
View File
@@ -0,0 +1,26 @@
// PowerSync React 接入 (0.2.1 离线编辑)
// 用法: 在 App 外层包 <PowerSyncProvider>; 任务模块内用 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 <PowerSyncContext.Provider value={db}>{children}</PowerSyncContext.Provider>
}
export { useQuery, useStatus, useSuspenseQuery, usePowerSync } from '@powersync/react'
+98
View File
@@ -0,0 +1,98 @@
// PowerSync 客户端 schema (0.2.1 离线编辑)
// 表名/列名与 deploy/0.2.1/powersync/sync-config.yaml 的输出严格一致 (PascalCase + text 型 id 由 SDK 自建)
// 见 docs/V0.2/离线编辑模块/0.2.1实施方案.md
import { column, Schema, Table } from '@powersync/web'
// 时间/JSON 列在同步流里以文本落地, 本地按字符串读写 (JSON 列为 JSON 字符串)
const project = new Table(
{
uuid: column.text,
code: column.text,
name: column.text,
ownerId: column.text,
isPersonal: column.integer,
archived: column.integer,
notes: column.text,
taskKinds: column.text,
icon: column.text,
autoArchive: column.text,
createdAt: column.text,
updatedAt: column.text,
},
{ indexes: { code: ['code'] } },
)
const projectMember = new Table({
uuid: column.text,
projectId: column.text,
userId: column.text,
permission: column.text,
createdAt: column.text,
})
const boardColumn = new Table(
{
uuid: column.text,
projectId: column.text,
name: column.text,
sortOrder: column.integer,
boundStatus: column.text,
retention: column.text,
createdAt: column.text,
updatedAt: column.text,
},
{ indexes: { project: ['projectId'] } },
)
const task = new Table(
{
uuid: column.text,
projectId: column.text,
columnId: column.text,
parentId: column.text,
title: column.text,
description: column.text,
kind: column.text,
markDate: column.text,
status: column.text,
doneAt: column.text,
assigneeId: column.text,
creatorId: column.text,
extras: column.text,
sortOrder: column.integer,
number: column.integer,
archived: column.integer,
archivedAt: column.text,
archiveSource: column.text,
createdAt: column.text,
updatedAt: column.text,
},
{ indexes: { project: ['projectId'], column: ['columnId'], number: ['projectId', 'number'] } },
)
const taskMember = new Table({
uuid: column.text,
taskId: column.text,
userId: column.text,
})
const taskTag = new Table({
uuid: column.text,
taskId: column.text,
name: column.text,
})
const user = new Table({
name: column.text,
email: column.text,
})
export const AppSchema = new Schema({
Project: project,
ProjectMember: projectMember,
BoardColumn: boardColumn,
Task: task,
TaskMember: taskMember,
TaskTag: taskTag,
User: user,
})
+2 -1
View File
@@ -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({
<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) }}
+2 -1
View File
@@ -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({
<div key={t.id}>
<TaskRow
task={t}
codePrefix={codePrefix}
onToggle={onToggle}
onClick={() => { if (!drag.justEnded()) onOpen(t) }}
onMore={onMore}
+2
View File
@@ -276,6 +276,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
<ListView
tasks={visibleTasks}
columns={columns}
codePrefix={project?.code}
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
onMore={openTaskMenu}
@@ -286,6 +287,7 @@ 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)}
+36
View File
@@ -70,6 +70,35 @@ input, textarea, select { font: inherit; }
background: var(--color-bg);
}
/* 离线标识 (0.2.1): 窗口左上角, 仅在离线/服务不可达时出现 */
.offline-badge {
position: fixed;
top: calc(var(--safe-top) + 4px);
left: 6px;
z-index: 120;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 77, 79, 0.12);
border: 1px solid rgba(255, 77, 79, 0.35);
color: #cf1322;
font-size: 10px;
line-height: 1;
font-weight: 600;
pointer-events: none;
user-select: none;
}
.offline-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff4d4f;
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.18);
}
.offline-text { letter-spacing: 1px; }
.app-main {
flex: 1 1 auto;
min-height: 0;
@@ -718,6 +747,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 {