From cee21a39d92bda5c386865d00168786fd9cb4957 Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Sun, 25 Oct 2026 12:02:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(V0.2):=20=E5=BF=AB=E9=80=9F=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=BE=93=E5=85=A5=E6=A1=86=E4=B8=89=E8=A1=8C/?= =?UTF-8?q?=E5=A4=B1=E7=84=A6=E6=94=B6=E8=B5=B7;=20=E2=8B=AF=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E7=82=B9=E9=80=89=E5=8D=B3=E6=94=B6;=20=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=9B=BE=E6=A0=87=E5=8F=AF=E8=AE=BE=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭, 焦点离开且无内容时自动收起 - 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单 - Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片, 客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位 显示图片, 保持原尺寸与圆角; 可恢复默认首字色块 --- .../20261025130000_project_icon/migration.sql | 2 + prisma/schema.prisma | 1 + server/routes/projects.js | 12 +++- src/components/task/RecentProjects.jsx | 9 ++- src/pages/task/ProjectManage.jsx | 64 ++++++++++++++++++- src/pages/task/ProjectView.jsx | 13 ++-- src/pages/task/TaskPage.jsx | 18 +++++- 7 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 prisma/migrations/20261025130000_project_icon/migration.sql diff --git a/prisma/migrations/20261025130000_project_icon/migration.sql b/prisma/migrations/20261025130000_project_icon/migration.sql new file mode 100644 index 0000000..927fb23 --- /dev/null +++ b/prisma/migrations/20261025130000_project_icon/migration.sql @@ -0,0 +1,2 @@ +-- 项目图标: data URL (客户端裁剪 96x96 JPEG, 约 10-20KB) +ALTER TABLE `Project` ADD COLUMN `icon` TEXT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9e7d406..5a0fe01 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -395,6 +395,7 @@ model Project { archived Boolean @default(false) notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13) taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类) + icon String? @db.Text // 项目图标 (data URL, 客户端裁剪为 96x96 JPEG) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/server/routes/projects.js b/server/routes/projects.js index a7b4fdb..6eecd6f 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -62,6 +62,7 @@ function projectSummary(p) { createdAt: p.createdAt, updatedAt: p.updatedAt, taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS, + icon: p.icon || null, memberCount: p._count?.members ?? undefined, taskCount: p._count?.tasks ?? undefined, } @@ -134,7 +135,7 @@ router.put('/:id', async (req, res, next) => { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) - const { name, archived, notes, taskKinds } = req.body || {} + const { name, archived, notes, taskKinds, icon } = req.body || {} const data = {} if (name !== undefined) { const t = String(name).trim() @@ -149,6 +150,15 @@ router.put('/:id', async (req, res, next) => { if (!kinds?.length) return res.status(400).json({ error: '至少保留一个任务类型' }) data.taskKinds = kinds } + // 项目图标: data URL (客户端已裁剪), null 清除回退首字色块 + if (icon !== undefined && icon !== null) { + const s = String(icon) + if (!s.startsWith('data:image/')) return res.status(400).json({ error: '图标格式非法' }) + if (s.length > 300_000) return res.status(400).json({ error: '图标过大' }) + data.icon = s + } else if (icon === null) { + data.icon = null + } if (archived !== undefined) data.archived = !!archived // 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换 if (notes !== undefined) { diff --git a/src/components/task/RecentProjects.jsx b/src/components/task/RecentProjects.jsx index 93551b0..da6bc18 100644 --- a/src/components/task/RecentProjects.jsx +++ b/src/components/task/RecentProjects.jsx @@ -123,8 +123,13 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore } onMouseDown={(ev) => beginPress(i, ev)} onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }} > -
- {(p.name || '?').slice(0, 1)} +
+ {p.icon ? '' : (p.name || '?').slice(0, 1)}
{p.name}
{p.taskCount ?? 0} 个任务
diff --git a/src/pages/task/ProjectManage.jsx b/src/pages/task/ProjectManage.jsx index a14d4a9..6fa6493 100644 --- a/src/pages/task/ProjectManage.jsx +++ b/src/pages/task/ProjectManage.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { Dialog, Input, Popup, Toast } from 'antd-mobile' import { projectApi } from '../../api/task.js' import { useAuth } from '../../auth/AuthContext.jsx' @@ -21,6 +21,8 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) const [kindEdit, setKindEdit] = useState(false) const [kinds, setKinds] = useState([]) const [newKind, setNewKind] = useState('') + // 项目图标更换: 隐藏 file input + const iconInputRef = useRef(null) const members = project.members || [] const notes = Array.isArray(project.notes) ? project.notes : [] @@ -149,6 +151,49 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) } } + // 项目图标: 图片裁为 96x96 cover JPEG data URL (保持现有尺寸/圆角由 CSS 承担) + function pickIcon() { iconInputRef.current?.click() } + + function onIconFile(e) { + const file = e.target.files?.[0] + e.target.value = '' + if (!file || !file.type.startsWith('image/')) return + const url = URL.createObjectURL(file) + const img = new Image() + img.onload = async () => { + URL.revokeObjectURL(url) + const S = 96 + const c = document.createElement('canvas') + c.width = S + c.height = S + const ctx = c.getContext('2d') + const r = Math.max(S / img.width, S / img.height) + const w = img.width * r + const h = img.height * r + ctx.drawImage(img, (S - w) / 2, (S - h) / 2, w, h) + const dataUrl = c.toDataURL('image/jpeg', 0.85) + try { + await projectApi.update(project.id, { icon: dataUrl }) + Toast.show({ content: '图标已更新' }) + onChanged?.() + } catch (err) { + Toast.show({ content: err.message || '图标保存失败' }) + } + } + img.onerror = () => URL.revokeObjectURL(url) + img.src = url + } + + async function clearIcon() { + try { + await projectApi.update(project.id, { icon: null }) + Toast.show({ content: '已恢复默认' }) + onChanged?.() + } catch (e) { + Toast.show({ content: e.message }) + } + } + async function quit() { const ok = await Dialog.confirm({ content: '退出该项目?退出后将不再可见。', confirmText: '退出', @@ -222,6 +267,23 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
项目信息
+
+ 项目图标 +
+ {project.icon ? '' : (project.name || '?').slice(0, 1)} +
+ {project.icon && 恢复} + 更换 + +
{ setName(project.name); setRenaming(true) }}> 项目名称 {project.name} diff --git a/src/pages/task/ProjectView.jsx b/src/pages/task/ProjectView.jsx index 2e28115..9558e8e 100644 --- a/src/pages/task/ProjectView.jsx +++ b/src/pages/task/ProjectView.jsx @@ -164,9 +164,9 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit } } - /** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */ + /** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 (点选后菜单收回) */ function openTaskMenu(task) { - ActionSheet.show({ + const handler = ActionSheet.show({ actions: [ { text: '编辑', key: 'edit' }, { text: '拷贝到其他项目', key: 'copy' }, @@ -174,6 +174,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit ], cancelText: '取消', onAction: async (item) => { + handler.close?.() if (item.key === 'edit') setOpenTask(task) else if (item.key === 'copy') setCopyTask(task) else if (item.key === 'delete') { @@ -239,13 +240,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit {adding && (
- { if (!e.currentTarget.value.trim()) setAdding(false) }} onKeyDown={(e) => { - if (e.key === 'Enter') createTask(e.currentTarget.value) + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); createTask(e.currentTarget.value) } if (e.key === 'Escape') setAdding(false) }} /> diff --git a/src/pages/task/TaskPage.jsx b/src/pages/task/TaskPage.jsx index 5ed2c6c..ae49d65 100644 --- a/src/pages/task/TaskPage.jsx +++ b/src/pages/task/TaskPage.jsx @@ -286,8 +286,13 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal, onMouseDown={(ev) => beginPress(p, ev)} onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }} > -
- {(p.name || '?').slice(0, 1)} +
+ {p.icon ? '' : (p.name || '?').slice(0, 1)}
@@ -315,7 +320,14 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
已归档({archived.length})
{archived.map((p) => (
onOpen(p.id)}> -
{(p.name || '?').slice(0, 1)}
+
+ {p.icon ? '' : (p.name || '?').slice(0, 1)} +
{p.name}
已归档 · {p.taskCount ?? 0} 个任务