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:
+5
-2
@@ -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
@@ -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
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user