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} 个任务