feat(V0.2): 快速添加输入框三行/失焦收起; ⋯菜单点选即收; 项目图标可设图片
Docker Build and Push / build-image (push) Successful in 1m22s
Docker Build and Push / build-image (push) Successful in 1m22s
- 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭, 焦点离开且无内容时自动收起 - 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单 - Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片, 客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位 显示图片, 保持原尺寸与圆角; 可恢复默认首字色块
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- 项目图标: data URL (客户端裁剪 96x96 JPEG, 约 10-20KB)
|
||||||
|
ALTER TABLE `Project` ADD COLUMN `icon` TEXT NULL;
|
||||||
@@ -395,6 +395,7 @@ model Project {
|
|||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
|
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
|
||||||
taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类)
|
taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类)
|
||||||
|
icon String? @db.Text // 项目图标 (data URL, 客户端裁剪为 96x96 JPEG)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function projectSummary(p) {
|
|||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
updatedAt: p.updatedAt,
|
updatedAt: p.updatedAt,
|
||||||
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
||||||
|
icon: p.icon || null,
|
||||||
memberCount: p._count?.members ?? undefined,
|
memberCount: p._count?.members ?? undefined,
|
||||||
taskCount: p._count?.tasks ?? undefined,
|
taskCount: p._count?.tasks ?? undefined,
|
||||||
}
|
}
|
||||||
@@ -134,7 +135,7 @@ router.put('/:id', async (req, res, next) => {
|
|||||||
const userId = req.user.sub
|
const userId = req.user.sub
|
||||||
const project = await loadProjectForUser(req.params.id, userId)
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
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 = {}
|
const data = {}
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
const t = String(name).trim()
|
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: '至少保留一个任务类型' })
|
if (!kinds?.length) return res.status(400).json({ error: '至少保留一个任务类型' })
|
||||||
data.taskKinds = kinds
|
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
|
if (archived !== undefined) data.archived = !!archived
|
||||||
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
||||||
if (notes !== undefined) {
|
if (notes !== undefined) {
|
||||||
|
|||||||
@@ -123,8 +123,13 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore }
|
|||||||
onMouseDown={(ev) => beginPress(i, ev)}
|
onMouseDown={(ev) => beginPress(i, ev)}
|
||||||
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
|
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
|
||||||
>
|
>
|
||||||
<div className="rp-ico" style={{ background: colorOf(p.id) }}>
|
<div
|
||||||
{(p.name || '?').slice(0, 1)}
|
className="rp-ico"
|
||||||
|
style={p.icon
|
||||||
|
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||||
|
: { background: colorOf(p.id) }}
|
||||||
|
>
|
||||||
|
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="rp-name">{p.name}</div>
|
<div className="rp-name">{p.name}</div>
|
||||||
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
|
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
|
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
|
||||||
import { projectApi } from '../../api/task.js'
|
import { projectApi } from '../../api/task.js'
|
||||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||||
@@ -21,6 +21,8 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
const [kindEdit, setKindEdit] = useState(false)
|
const [kindEdit, setKindEdit] = useState(false)
|
||||||
const [kinds, setKinds] = useState([])
|
const [kinds, setKinds] = useState([])
|
||||||
const [newKind, setNewKind] = useState('')
|
const [newKind, setNewKind] = useState('')
|
||||||
|
// 项目图标更换: 隐藏 file input
|
||||||
|
const iconInputRef = useRef(null)
|
||||||
|
|
||||||
const members = project.members || []
|
const members = project.members || []
|
||||||
const notes = Array.isArray(project.notes) ? project.notes : []
|
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({
|
async function quit() { const ok = await Dialog.confirm({
|
||||||
content: '退出该项目?退出后将不再可见。',
|
content: '退出该项目?退出后将不再可见。',
|
||||||
confirmText: '退出',
|
confirmText: '退出',
|
||||||
@@ -222,6 +267,23 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
|
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
|
||||||
|
<div className="set-row" style={{ padding: '12px 14px' }}>
|
||||||
|
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目图标</span>
|
||||||
|
<div
|
||||||
|
className="pico"
|
||||||
|
style={{
|
||||||
|
width: 34, height: 34, borderRadius: 9, flexShrink: 0,
|
||||||
|
background: project.icon ? `url(${project.icon}) center/cover no-repeat` : '#dfe6f0',
|
||||||
|
color: '#5a6a80', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={pickIcon}
|
||||||
|
>
|
||||||
|
{project.icon ? '' : (project.name || '?').slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
{project.icon && <span className="link" style={{ marginLeft: 10 }} onClick={clearIcon}>恢复</span>}
|
||||||
|
<span className="link" style={{ marginLeft: 10 }} onClick={pickIcon}>更换</span>
|
||||||
|
<input ref={iconInputRef} type="file" accept="image/*" hidden onChange={onIconFile} />
|
||||||
|
</div>
|
||||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
|
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
|
||||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
|
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
|
||||||
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
|
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */
|
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 (点选后菜单收回) */
|
||||||
function openTaskMenu(task) {
|
function openTaskMenu(task) {
|
||||||
ActionSheet.show({
|
const handler = ActionSheet.show({
|
||||||
actions: [
|
actions: [
|
||||||
{ text: '编辑', key: 'edit' },
|
{ text: '编辑', key: 'edit' },
|
||||||
{ text: '拷贝到其他项目', key: 'copy' },
|
{ text: '拷贝到其他项目', key: 'copy' },
|
||||||
@@ -174,6 +174,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
],
|
],
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onAction: async (item) => {
|
onAction: async (item) => {
|
||||||
|
handler.close?.()
|
||||||
if (item.key === 'edit') setOpenTask(task)
|
if (item.key === 'edit') setOpenTask(task)
|
||||||
else if (item.key === 'copy') setCopyTask(task)
|
else if (item.key === 'copy') setCopyTask(task)
|
||||||
else if (item.key === 'delete') {
|
else if (item.key === 'delete') {
|
||||||
@@ -239,13 +240,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
|
|
||||||
{adding && (
|
{adding && (
|
||||||
<div className="quick-add">
|
<div className="quick-add">
|
||||||
<input
|
<textarea
|
||||||
className="inp"
|
className="inp"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
rows={3}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
placeholder="输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词"
|
placeholder={'输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词\n超过 30 字自动截断为标题,全文进描述'}
|
||||||
|
onBlur={(e) => { if (!e.currentTarget.value.trim()) setAdding(false) }}
|
||||||
onKeyDown={(e) => {
|
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)
|
if (e.key === 'Escape') setAdding(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -286,8 +286,13 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
|
|||||||
onMouseDown={(ev) => beginPress(p, ev)}
|
onMouseDown={(ev) => beginPress(p, ev)}
|
||||||
onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }}
|
onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }}
|
||||||
>
|
>
|
||||||
<div className="pico" style={{ background: colorOf(p) }}>
|
<div
|
||||||
{(p.name || '?').slice(0, 1)}
|
className="pico"
|
||||||
|
style={p.icon
|
||||||
|
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||||
|
: { background: colorOf(p) }}
|
||||||
|
>
|
||||||
|
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pmain">
|
<div className="pmain">
|
||||||
<div className="ptitle">
|
<div className="ptitle">
|
||||||
@@ -315,7 +320,14 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
|
|||||||
<div className="proj-group">已归档({archived.length})</div>
|
<div className="proj-group">已归档({archived.length})</div>
|
||||||
{archived.map((p) => (
|
{archived.map((p) => (
|
||||||
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
|
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
|
||||||
<div className="pico" style={{ background: '#a8b0ba' }}>{(p.name || '?').slice(0, 1)}</div>
|
<div
|
||||||
|
className="pico"
|
||||||
|
style={p.icon
|
||||||
|
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||||
|
: { background: '#a8b0ba' }}
|
||||||
|
>
|
||||||
|
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||||
|
</div>
|
||||||
<div className="pmain">
|
<div className="pmain">
|
||||||
<div className="ptitle">{p.name}</div>
|
<div className="ptitle">{p.name}</div>
|
||||||
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
|
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user