feat: complete project workspace tags and notes UI

This commit is contained in:
2026-07-21 00:46:09 +08:00
parent 54958382f1
commit c613d4c997
28 changed files with 1621 additions and 284 deletions

View File

@@ -5,13 +5,14 @@ const requiredFiles = [
'src/pages/login.tsx',
'src/pages/workspace-body.tsx',
'src/pages/workspace-home.tsx',
'src/pages/workspace-inbox.tsx',
'src/pages/workspace-explore.tsx',
'src/pages/projects/project-overview.tsx',
'src/pages/projects/project-channel-page.tsx',
'src/pages/projects/project-tasks.tsx',
'src/pages/projects/project-ai.tsx',
'src/pages/projects/project-notes.tsx',
'src/pages/projects/project-cron.tsx',
'src/pages/projects/project-new-channel.tsx',
'src/pages/projects/project-rail.tsx',
'src/pages/projects/project-sidebar.tsx',
'src/pages/projects/project-topbar.tsx',

View File

@@ -38,7 +38,7 @@ const collectMetrics = async () => page.evaluate(() => {
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
.map((button) => button.textContent?.trim())
const dashboard = document.querySelector('.dashboard-button')
const workspaceInbox = document.querySelector('.dashboard-button[title="Inbox"]')
const workspaceExplore = document.querySelector('.dashboard-button[title="探索"]')
const project = document.querySelector('.project-button')
const create = document.querySelector('.create-project')
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
@@ -84,8 +84,8 @@ const collectMetrics = async () => page.evaluate(() => {
workspaceActions,
dashboardButton: rect(dashboard),
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
workspaceInboxButton: rect(workspaceInbox),
workspaceInboxButtonActive: workspaceInbox?.classList.contains('active') ?? false,
workspaceExploreButton: rect(workspaceExplore),
workspaceExploreButtonActive: workspaceExplore?.classList.contains('active') ?? false,
projectButton: rect(project),
projectButtonActive: project?.classList.contains('active') ?? false,
createProjectButton: rect(create),
@@ -111,10 +111,10 @@ await page.waitForTimeout(700)
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
const workspaceMetrics = await collectMetrics()
await page.locator('.dashboard-button[title="Inbox"]').click()
await page.locator('.dashboard-button[title="探索"]').click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/workspace-inbox-react-acro-light.png', fullPage: true })
const workspaceInboxMetrics = await collectMetrics()
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
const workspaceExploreMetrics = await collectMetrics()
await page.locator('.project-button').first().click()
await page.waitForTimeout(500)
@@ -133,7 +133,8 @@ for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: 'Cron 计划任务', pageClass: 'project-cron-page', expectedHeading: 'Cron 计划任务' },
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
]) {
await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250)
@@ -158,7 +159,7 @@ await server.close()
console.log(JSON.stringify({
workspaceMetrics,
workspaceInboxMetrics,
workspaceExploreMetrics,
projectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
@@ -180,8 +181,8 @@ if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.workbenchMain) failures.push('missing workbench main')
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
if (!metrics.workspaceInboxButton) failures.push('expected fixed workspace inbox button to render in project rail')
if (metrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button not to be active after login')
if (!metrics.workspaceExploreButton) failures.push('expected fixed workspace explore button to render in project rail')
if (metrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button not to be active after login')
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`)
if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`)
@@ -231,11 +232,11 @@ if (
failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (
!metrics.workspaceInboxButton ||
metrics.workspaceInboxButton.width !== metrics.projectButton?.width ||
metrics.workspaceInboxButton.height !== metrics.projectButton?.height
!metrics.workspaceExploreButton ||
metrics.workspaceExploreButton.width !== metrics.projectButton?.width ||
metrics.workspaceExploreButton.height !== metrics.projectButton?.height
) {
failures.push(`workspace inbox button size ${JSON.stringify(metrics.workspaceInboxButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
failures.push(`workspace explore button size ${JSON.stringify(metrics.workspaceExploreButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (
!metrics.createProjectButton ||
@@ -245,14 +246,14 @@ if (
failures.push(`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (!workspaceInboxMetrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button to become active when selected')
if (workspaceInboxMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace inbox page')
if (workspaceInboxMetrics.channelSidebar) failures.push(`expected workspace inbox to hide channel sidebar, got ${JSON.stringify(workspaceInboxMetrics.channelSidebar)}`)
if (workspaceInboxMetrics.inspector) failures.push(`expected workspace inbox to hide inspector, got ${JSON.stringify(workspaceInboxMetrics.inspector)}`)
if (!workspaceInboxMetrics.stage || !workspaceInboxMetrics.projectRail) {
failures.push(`missing workspace inbox layout regions: rail=${JSON.stringify(workspaceInboxMetrics.projectRail)}, stage=${JSON.stringify(workspaceInboxMetrics.stage)}`)
} else if (Math.abs(workspaceInboxMetrics.stage.left - workspaceInboxMetrics.projectRail.right) > 1) {
failures.push(`expected workspace inbox stage to start after project rail, got stage.left=${workspaceInboxMetrics.stage.left}, rail.right=${workspaceInboxMetrics.projectRail.right}`)
if (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) {
failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`)
} else if (Math.abs(workspaceExploreMetrics.stage.left - workspaceExploreMetrics.projectRail.right) > 1) {
failures.push(`expected workspace explore stage to start after project rail, got stage.left=${workspaceExploreMetrics.stage.left}, rail.right=${workspaceExploreMetrics.projectRail.right}`)
}
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')

View File

@@ -347,6 +347,11 @@
color: white;
}
.dashboard-button .arco-icon,
.create-project .arco-icon {
font-size: 24px;
}
.create-project {
font-size: 11px;
white-space: normal;
@@ -399,6 +404,18 @@
margin: 0;
}
.project-title-icon {
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
border-radius: 6px;
background: var(--senlin-soft-blue);
color: var(--senlin-primary);
font-size: 12px;
font-weight: 700;
}
.channel-list {
display: grid;
gap: 4px;
@@ -429,6 +446,11 @@
box-shadow: inset 3px 0 0 var(--senlin-primary);
}
.channel-badge-muted .arco-badge-number {
background: #c9cdd4;
color: #4e5969;
}
.channel-left {
min-width: 0;
display: flex;
@@ -452,6 +474,17 @@
font-weight: 700;
}
.tag-create-button.arco-btn {
height: 24px;
padding: 0 8px;
border-radius: 12px;
color: rgb(var(--arcoblue-6));
}
.tag-create-button .arco-icon {
font-size: 12px;
}
.recent-sessions button {
display: grid;
gap: 2px;
@@ -495,6 +528,85 @@
margin: 3px 0 0;
}
.project-overview-hero {
min-height: 112px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
border: 1px solid var(--senlin-border);
border-radius: 8px;
background-position: center;
background-size: cover;
color: #fff;
padding: 16px 18px;
overflow: hidden;
}
.project-overview-identity {
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
}
.project-overview-icon {
width: 44px;
height: 44px;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 42%);
border-radius: 8px;
background: rgb(255 255 255 / 18%);
color: #fff;
font-size: 16px;
font-weight: 700;
backdrop-filter: blur(10px);
}
.project-overview-identity h3,
.project-overview-identity .arco-typography {
color: #fff;
}
.project-overview-identity h3 {
margin: 0 0 3px;
}
.project-overview-identity .arco-typography {
max-width: 680px;
margin: 0;
opacity: 0.88;
}
.project-overview-share {
max-width: min(520px, 42vw);
display: grid;
justify-items: end;
gap: 4px;
}
.project-overview-share .arco-typography {
color: rgb(255 255 255 / 82%);
}
.project-overview-share .arco-btn {
height: 30px;
max-width: 100%;
border-color: rgb(255 255 255 / 36%);
background: rgb(255 255 255 / 18%);
color: #fff;
padding: 0 10px;
backdrop-filter: blur(10px);
}
.project-overview-share .arco-btn > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-row {
min-width: 0;
}
@@ -579,6 +691,61 @@
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
}
.overview-task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
padding-top: 6px;
}
.overview-task-card {
min-width: 0;
min-height: 132px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--senlin-border);
border-radius: 6px;
background: var(--senlin-surface);
color: var(--senlin-text);
padding: 14px;
text-align: left;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.overview-task-card:hover {
border-color: color-mix(in srgb, var(--senlin-primary) 42%, var(--senlin-border));
box-shadow: 0 8px 22px color-mix(in srgb, var(--senlin-primary) 12%, transparent);
transform: translateY(-1px);
}
.overview-task-card.completed {
background: color-mix(in srgb, var(--senlin-surface) 92%, #00b42a);
}
.overview-task-title {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.overview-task-title svg {
color: var(--senlin-primary);
}
.overview-task-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
color: var(--senlin-muted);
font-size: 12px;
}
.workspace-task-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -646,6 +813,109 @@
grid-template-columns: minmax(260px, 1fr) 80px 160px 80px;
}
.project-file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 8px 18px;
}
.project-file-grid.compact {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.project-file-tile {
min-width: 0;
height: 76px;
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
align-items: center;
gap: 12px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: var(--senlin-text);
padding: 8px 10px;
text-align: left;
cursor: pointer;
}
.project-file-tile:hover,
.project-file-tile:focus-visible {
border-color: #bfdbff;
background: #e8f3ff;
outline: none;
}
.theme-dark .project-file-tile:hover,
.theme-dark .project-file-tile:focus-visible {
border-color: color-mix(in srgb, var(--senlin-primary) 56%, var(--senlin-border));
background: color-mix(in srgb, var(--senlin-primary) 18%, var(--senlin-surface));
}
.project-file-icon {
width: 54px;
height: 54px;
display: inline-grid;
place-items: center;
border-radius: 6px;
background: #eef2f7;
color: #4e5969;
font-size: 30px;
}
.project-file-icon.folder {
background: #fff3cf;
color: #f0a020;
}
.project-file-icon.pdf {
background: #fff1f0;
color: #f53f3f;
}
.project-file-icon.sheet {
background: #e8ffea;
color: #00b42a;
}
.project-file-icon.doc,
.project-file-icon.note {
background: #e8f3ff;
color: #165dff;
}
.project-file-icon.image {
background: #f2f3f5;
color: #86909c;
}
.project-file-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.project-file-name,
.project-file-type,
.project-file-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-file-name {
color: var(--senlin-text);
font-size: 14px;
font-weight: 500;
}
.project-file-type,
.project-file-meta {
color: var(--senlin-muted);
font-size: 12px;
}
.project-channel-page {
min-width: 0;
}
@@ -815,6 +1085,10 @@
padding: 12px;
}
.channel-editor-card .arco-card-body {
padding: 14px;
}
.action-modal .arco-modal-content {
border-radius: 8px;
}

View File

@@ -16,10 +16,14 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
const project: Project = {
id: String(payload.project.id),
name: payload.project.name,
short: payload.project.initials,
identifier: payload.project.identifier ?? '',
icon: payload.project.icon ?? '',
background: payload.project.background ?? '',
description: payload.project.description,
short: projectIconLabel(payload.project.icon ?? '', payload.project.initials),
badge: payload.project.unreadCount,
urgent: payload.project.unreadCount > 20,
color: projectColors[index % projectColors.length],
color: projectColor(payload.project.background ?? '', index),
}
return {
@@ -35,6 +39,22 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
}
}
function projectIconLabel(icon: string, fallback: string) {
const trimmed = icon.trim()
if (!trimmed) return fallback
const chars = Array.from(trimmed)
if (chars.length <= 2) return trimmed
return chars.slice(0, 2).join('').toUpperCase()
}
function projectColor(background: string, index: number) {
const trimmed = background.trim()
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) {
return trimmed
}
return projectColors[index % projectColors.length]
}
function mapChannel(channel: BackendChannel): ProjectChannel {
return {
id: channel.id,
@@ -79,7 +99,7 @@ function channelLabel(channel: BackendChannel) {
case 'notes_sources':
return '笔记资料'
case 'cron':
return 'Cron 计划任务'
return '计划任务'
default:
return channel.title
}

View File

@@ -5,6 +5,12 @@ export type BackendProject = {
id?: number
Name?: string
name?: string
Identifier?: string
identifier?: string
Icon?: string
icon?: string
Background?: string
background?: string
Description?: string
description?: string
}
@@ -12,6 +18,9 @@ export type BackendProject = {
export type BackendWorkspaceProject = {
id: number
name: string
identifier?: string
icon?: string
background?: string
description: string
initials: string
unreadCount: number
@@ -88,6 +97,13 @@ export type BackendProjectWorkspace = {
cronPlans: BackendCronPlan[]
}
export type BackendTag = {
ID?: number
id?: number
Name?: string
name?: string
}
export async function fetchProjects(session: ApiSession) {
return apiRequest<BackendProject[]>('/api/projects', { token: session.token })
}
@@ -96,8 +112,15 @@ export async function fetchProjectWorkspace(session: ApiSession, projectId: stri
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
}
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
return apiRequest<BackendTag[]>(`/api/projects/${projectId}/tags`, { token: session.token })
}
export type CreateProjectInput = {
name: string
identifier?: string
icon?: string
background?: string
description?: string
}
@@ -106,6 +129,15 @@ export type CreateTaskInput = {
description?: string
status?: string
dueAt?: string
tag?: string
}
export type UpdateTaskInput = {
title: string
description?: string
completed: boolean
nextProjectId?: number
tag?: string
}
export type UploadSourceInput = {
@@ -120,6 +152,10 @@ export type CreateCronPlanInput = {
nextRunAt?: string
}
export type CreateProjectTagInput = {
name: string
}
export async function createProject(session: ApiSession, input: CreateProjectInput) {
return apiRequest<BackendProject>('/api/projects', {
method: 'POST',
@@ -136,6 +172,14 @@ export async function createTask(session: ApiSession, projectId: string | number
})
}
export async function updateTask(session: ApiSession, projectId: string | number, taskId: string | number, input: UpdateTaskInput) {
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks/${taskId}`, {
method: 'PATCH',
token: session.token,
body: input,
})
}
export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) {
const body = new FormData()
body.append('file', input.file)
@@ -154,3 +198,11 @@ export async function createCronPlan(session: ApiSession, projectId: string | nu
body: input,
})
}
export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) {
return apiRequest<BackendTag>(`/api/projects/${projectId}/tags`, {
method: 'POST',
token: session.token,
body: input,
})
}

View File

@@ -7,14 +7,18 @@ import { mapWorkspace } from '../api/mappers'
import {
createCronPlan,
createProject,
createProjectTag,
createTask,
fetchProjectWorkspace,
fetchProjects,
updateTask,
uploadSource,
} from '../api/projects'
import { LoginPage } from '../pages/login'
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
import { ProjectPage } from '../pages/workspace-home'
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
function App() {
@@ -26,13 +30,14 @@ function App() {
const [activeProjectID, setActiveProjectID] = useState<string>('')
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
const [, setSelectedItem] = useState('Inbox 待处理36')
const [, setSelectedItem] = useState('探索采集')
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
const dark = theme === 'dark'
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
const backendProjects = await fetchProjects(nextSession)
@@ -102,7 +107,13 @@ function App() {
return
}
void runAction(async () => {
const created = await createProject(requireSession(), { name: draft.name.trim(), description: draft.description.trim() })
const created = await createProject(requireSession(), {
name: draft.name.trim(),
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
const nextProjectID = String(created.ID ?? created.id ?? '')
await refreshAfterAction(nextProjectID)
if (nextProjectID) {
@@ -124,6 +135,7 @@ function App() {
description: draft.description.trim(),
status: 'open',
dueAt: normalizeOptionalTime(draft.dueAt),
tag: draft.tag.trim(),
})
await refreshAfterAction()
setActiveChannel('tasks')
@@ -167,6 +179,18 @@ function App() {
}, '计划任务已创建')
}
function handleCreateProjectTag(name: string) {
const trimmedName = name.trim()
if (!trimmedName) {
Message.warning('请输入标签名称')
return
}
void runAction(async () => {
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
await refreshAfterAction()
}, '标签已创建')
}
function openTask(project: Project, taskID: string) {
setActiveProjectID(project.id)
setActiveView('project')
@@ -174,6 +198,38 @@ function App() {
setActiveTaskID(taskID)
}
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
void runAction(async () => {
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
title: update.title,
description: update.summary,
completed: update.completed,
nextProjectId: Number(update.nextProjectId),
tag: update.tag,
})
await refreshAfterAction(update.nextProjectId)
}, '任务已更新')
}
function handleUpdateProject(update: ProjectSettingsUpdate) {
setWorkspaces((current) =>
current.map((workspace) => {
if (workspace.project.id !== update.projectId) return workspace
const nextProject = {
...workspace.project,
name: update.name,
identifier: update.identifier,
icon: update.icon,
background: update.background,
description: update.description,
short: projectIconLabel(update.icon, update.identifier || update.name.slice(0, 2)),
color: projectColor(update.background, workspace.project.color),
}
return { ...workspace, project: nextProject }
}),
)
}
return (
<ConfigProvider>
<main className={dark ? 'app theme-dark' : 'app'}>
@@ -190,7 +246,7 @@ function App() {
activeTaskID={activeTaskID}
theme={theme}
onSelectWorkspace={() => setActiveView('workspace')}
onSelectWorkspaceInbox={() => setActiveView('workspace-inbox')}
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
onSelectProject={(project) => {
setActiveProjectID(project.id)
setActiveView('project')
@@ -209,6 +265,9 @@ function App() {
onCreateTask={() => setActiveModal('task')}
onUploadSource={() => setActiveModal('source')}
onCreateCronPlan={() => setActiveModal('cron')}
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
onUpdateProject={handleUpdateProject}
onCreateProjectTag={handleCreateProjectTag}
/>
) : (
<Spin loading />
@@ -221,10 +280,25 @@ function App() {
onCreateTask={handleCreateTask}
onUploadSource={handleUploadSource}
onCreateCronPlan={handleCreateCronPlan}
tagOptions={activeTagOptions}
/>
</main>
</ConfigProvider>
)
}
function projectIconLabel(icon: string, fallback: string) {
const trimmed = icon.trim()
if (!trimmed) return fallback
const chars = Array.from(trimmed)
if (chars.length <= 2) return trimmed
return chars.slice(0, 2).join('').toUpperCase()
}
function projectColor(background: string, fallback: string) {
const trimmed = background.trim()
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) return trimmed
return fallback
}
export default App

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Input, Modal, Space, Switch, Typography } from '@arco-design/web-react'
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
const { Text } = Typography
const { TextArea } = Input
@@ -8,6 +8,9 @@ export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
export type ProjectDraft = {
name: string
identifier: string
icon: string
background: string
description: string
}
@@ -15,6 +18,7 @@ export type TaskDraft = {
title: string
description: string
dueAt: string
tag: string
}
export type SourceDraft = {
@@ -37,6 +41,7 @@ export function ProjectActionModals({
onCreateTask,
onUploadSource,
onCreateCronPlan,
tagOptions,
}: {
activeModal: ProjectActionModal
loading: boolean
@@ -45,16 +50,17 @@ export function ProjectActionModals({
onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void
tagOptions: string[]
}) {
const [project, setProject] = useState<ProjectDraft>({ name: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '' })
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '', tag: '' })
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
useEffect(() => {
if (activeModal === null) {
setProject({ name: '', description: '' })
setTask({ title: '', description: '', dueAt: '' })
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
setTask({ title: '', description: '', dueAt: '', tag: '' })
setSource({ title: '', file: null })
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
}
@@ -76,7 +82,19 @@ export function ProjectActionModals({
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
</label>
<label>
<Text></Text>
<Text></Text>
<Input placeholder="例如A3 / EXP / DATA" value={project.identifier} onChange={(identifier) => setProject((draft) => ({ ...draft, identifier }))} />
</label>
<label>
<Text></Text>
<Input placeholder="例如A3 / 🚀 / compass" value={project.icon} onChange={(icon) => setProject((draft) => ({ ...draft, icon }))} />
</label>
<label>
<Text></Text>
<Input placeholder="例如:#165DFF 或背景图片 URL" value={project.background} onChange={(background) => setProject((draft) => ({ ...draft, background }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
</label>
</Space>
@@ -103,6 +121,19 @@ export function ProjectActionModals({
<Text></Text>
<Input placeholder="2026-07-21T09:30:00Z可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
</label>
<label>
<Text></Text>
<Select
allowCreate
placeholder="选择或输入标签"
value={task.tag}
onChange={(tag) => setTask((draft) => ({ ...draft, tag }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
</Space>
</Modal>

View File

@@ -1,5 +1,6 @@
import { ProjectAi } from './project-ai'
import { ProjectCron } from './project-cron'
import { ProjectNewChannel } from './project-new-channel'
import { ProjectNotes } from './project-notes'
import { ProjectOverview } from './project-overview'
import { ProjectTasks } from './project-tasks'
@@ -35,6 +36,8 @@ export function ProjectChannelPage({
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron':
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
case 'new-channel':
return <ProjectNewChannel activeWorkspace={activeWorkspace} />
case 'overview':
default:
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />

View File

@@ -13,7 +13,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<div className="project-channel-page project-cron-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Cron </Title>
<Title heading={4}></Title>
<Text type="secondary">{project.name} AI </Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}></Button>

View File

@@ -0,0 +1,57 @@
import { IconFile, IconFileImage, IconFilePdf, IconFolder } from '@arco-design/web-react/icon'
import type { NoteSource } from './project-types'
export type ProjectFileItem = NoteSource | {
id: string
title: string
type: string
updated: string
owner: string
source?: string
}
export function ProjectFileGrid({
items,
onSelectItem,
compact = false,
}: {
items: ProjectFileItem[]
onSelectItem: (title: string) => void
compact?: boolean
}) {
return (
<div className={compact ? 'project-file-grid compact' : 'project-file-grid'}>
{items.map((item) => {
const fileKind = inferFileKind(item)
return (
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(item.title)}>
<span className={`project-file-icon ${fileKind.kind}`}>{fileIcon(fileKind.kind)}</span>
<span className="project-file-copy">
<span className="project-file-name">{item.title}</span>
<span className="project-file-type">{fileKind.label}</span>
<span className="project-file-meta">{fileKind.meta}</span>
</span>
</button>
)
})}
</div>
)
}
function inferFileKind(item: ProjectFileItem) {
const value = `${item.title} ${item.type} ${item.source ?? ''}`.toLowerCase()
if (item.type === 'folder' || item.source === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
if (value.includes('pdf')) return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
if (value.includes('png') || value.includes('jpg') || value.includes('jpeg') || value.includes('图片')) return { kind: 'image', label: '图片文件', meta: item.updated }
if (value.includes('excel') || value.includes('xlsx') || value.includes('表')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
if (value.includes('word') || value.includes('doc') || value.includes('文档')) return { kind: 'doc', label: '文档', meta: item.updated }
if (value.includes('markdown') || value.includes('笔记')) return { kind: 'note', label: 'Markdown 笔记', meta: item.updated }
return { kind: 'file', label: item.type || '文件', meta: item.updated }
}
function fileIcon(kind: string) {
if (kind === 'folder') return <IconFolder />
if (kind === 'pdf') return <IconFilePdf />
if (kind === 'image') return <IconFileImage />
return <IconFile />
}

View File

@@ -0,0 +1,62 @@
import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
import { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
const { TextArea } = Input
export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: ProjectWorkspace }) {
return (
<div className="project-channel-page project-new-channel-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{activeWorkspace.project.name} </Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
</div>
<Card className="queue-section channel-editor-card" bordered>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="例如:客户研究频道" />
</label>
<label>
<Text></Text>
<Select defaultValue="link">
<Select.Option value="link"></Select.Option>
<Select.Option value="source"></Select.Option>
<Select.Option value="workflow"></Select.Option>
</Select>
</label>
<label>
<Text></Text>
<Input placeholder="例如link / user / file" prefix={<IconApps />} />
</label>
<label>
<Text></Text>
<Input placeholder="https://example.com/channel" prefix={<IconLink />} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="频道用途、维护规则或采集说明" />
</label>
</Space>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="gray"></Tag>
</div>
<Space wrap>
<Tag color="arcoblue"></Tag>
<Tag color="green"></Tag>
<Tag color="orange">RSS/</Tag>
<Tag color="purple">AI </Tag>
</Space>
</Card>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { Button, Card, Space, Tag, Typography } from '@arco-design/web-react'
import { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon'
import { Button, Card, Space, Typography } from '@arco-design/web-react'
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
@@ -22,31 +23,26 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
</Space>
</div>
<div className="folder-grid">
{folders.map((folder) => (
<Card className="folder-card" bordered key={folder}>
<Space>
<Tag color="arcoblue"><IconFolder /></Tag>
<Text>{folder}</Text>
</Space>
</Card>
))}
</div>
<ProjectFileGrid items={folderItems(folders)} onSelectItem={onSelectItem} />
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{notes.map((note) => (
<button className="data-row note-row" key={note.title} onClick={() => onSelectItem(note.title)}>
<span className="row-title"><IconFile /> {note.title}</span>
<Tag>{note.type}</Tag>
<span>{note.updated}</span>
<span>{note.owner}</span>
</button>
))}
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
</Card>
</div>
)
}
function folderItems(names: string[]) {
return names.map((name) => ({
id: `folder-${name}`,
title: name,
type: 'folder',
updated: '文件夹',
owner: '',
source: 'folder',
}))
}

View File

@@ -1,43 +1,50 @@
import { useMemo } from 'react'
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCalendar, IconCheckCircleFill, IconClockCircle, IconEmail, IconFile, IconMore, IconRobot } from '@arco-design/web-react/icon'
import type { InboxItem, ProjectWorkspace } from './project-types'
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Row, Col } = Grid
const { Title, Text } = Typography
const { Title, Text, Paragraph } = Typography
export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
const { inbox, tasks, aiSessions, notes, cronJobs } = activeWorkspace
const { project, tasks, notes, cronJobs } = activeWorkspace
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
const metrics = useMemo(() => [
{ title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: <IconEmail />, color: 'arcoblue' },
{ title: '进行中的任务', value: tasks.filter((task) => !task.completed).length, delta: '实时接口', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话活跃', value: aiSessions.length, delta: '实时接口', icon: <IconRobot />, color: 'purple' },
{ title: '笔记资料总数', value: notes.length, delta: '实时接口', icon: <IconFile />, color: 'cyan' },
{ title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: <IconClockCircle />, color: 'orange' },
], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks])
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
{ title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
{ title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
return (
<div className="overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<div className="overview-page project-overview-page">
<section className="project-overview-hero" style={{ background: projectHeroBackground(project.background, project.color) }}>
<div className="project-overview-identity">
<span className="project-overview-icon">{project.short}</span>
<div>
<Title heading={3}>{project.name}</Title>
<Paragraph ellipsis={{ rows: 2 }}>{project.description || '暂无简介'}</Paragraph>
</div>
</div>
<Space>
<Button icon={<IconCalendar />}>2026-07-20</Button>
<Button icon={<IconMore />} />
</Space>
</div>
<div className="project-overview-share">
<Text> URL</Text>
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
</div>
</section>
<Row gutter={12} className="metric-row">
{metrics.map((metric) => (
<Col span={24 / metrics.length} key={metric.title}>
<Col span={6} key={metric.title}>
<Card className="metric-card" bordered>
<Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag>
<div>
<Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title>
<Text className="metric-delta">{metric.delta}</Text>
<Text className="metric-delta"></Text>
</div>
</Space>
</Card>
@@ -45,69 +52,40 @@ export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorks
))}
</Row>
<QueueSection title={`Inbox 待处理(${inbox.length}`} items={inbox} onSelectItem={onSelectItem} />
<TaskSection tasks={tasks} onSelectItem={onSelectItem} />
<AISessionSection aiSessions={aiSessions} onSelectItem={onSelectItem} />
<TaskCardSection title={`进行中的任务(${runningTasks.length}`} tasks={runningTasks} emptyText="当前没有进行中的任务" onSelectItem={onSelectItem} />
<TaskCardSection title={`完成的任务(${completedTasks.length}`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
<NoteSection notes={notes} onSelectItem={onSelectItem} />
</div>
)
}
function QueueSection({ title, items, onSelectItem }: { title: string; items: InboxItem[]; onSelectItem: (title: string) => void }) {
function TaskCardSection({ title, tasks, emptyText, onSelectItem }: { title: string; tasks: TaskItem[]; emptyText: string; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<Card className="queue-section overview-task-section" bordered>
<div className="section-header">
<Title heading={6}>{title}</Title>
<Button type="text" size="mini"></Button>
</div>
{items.map((item) => (
<button key={item.title} className="data-row" onClick={() => onSelectItem(item.title)}>
<span className="row-title"><IconFile /> {item.title}</span>
<span>{item.meta}</span>
<Tag>{item.tag}</Tag>
<span>{item.owner}</span>
<time>{item.time}</time>
</button>
))}
</Card>
)
}
function TaskSection({ tasks, onSelectItem }: { tasks: ProjectWorkspace['tasks']; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>{tasks.length}</Title>
<Button type="text" size="mini"></Button>
</div>
{tasks.map((task) => (
<button key={task.title} className="data-row task-row" onClick={() => onSelectItem(task.title)}>
<span className="row-title"><IconCheckCircleFill /> {task.title}</span>
<Tag color="arcoblue">{task.tag}</Tag>
<span> {task.progress}</span>
<span> {task.owner}</span>
<time> {task.createdAt}</time>
</button>
))}
</Card>
)
}
function AISessionSection({ aiSessions, onSelectItem }: { aiSessions: ProjectWorkspace['aiSessions']; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>AI {aiSessions.length}</Title>
<Button type="text" size="mini"></Button>
</div>
{aiSessions.map((session) => (
<button key={session.title} className="data-row ai-row" onClick={() => onSelectItem(session.title)}>
<span className="row-title"><IconRobot /> {session.title}</span>
<span>{session.summary}</span>
<span>{session.owner}</span>
<time>{session.time}</time>
</button>
))}
{tasks.length === 0 ? (
<Text type="secondary">{emptyText}</Text>
) : (
<div className="overview-task-grid">
{tasks.map((task) => (
<button key={task.id} className={task.completed ? 'overview-task-card completed' : 'overview-task-card'} onClick={() => onSelectItem(task.title)}>
<span className="overview-task-title">
<IconCheckCircleFill />
{task.title}
</span>
<Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无任务内容'}</Text>
<div className="overview-task-meta">
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || (task.completed ? '已完成' : '进行中')}</Tag>
<span> {task.progress}</span>
<span> {task.createdAt}</span>
</div>
</button>
))}
</div>
)}
</Card>
)
}
@@ -116,17 +94,27 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>5</Title>
<Title heading={6}>{notes.length}</Title>
<Button type="text" size="mini"></Button>
</div>
{notes.map((note) => (
<button key={note.title} className="data-row note-row" onClick={() => onSelectItem(note.title)}>
<span className="row-title"><IconFile /> {note.title}</span>
<Tag>{note.type}</Tag>
<span> {note.updated}</span>
<span>{note.owner}</span>
</button>
))}
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
</Card>
)
}
function projectShareURL(identifier: string) {
const slug = encodeURIComponent(identifier.trim() || 'project')
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
return `${origin}/share/projects/${slug}`
}
function projectHeroBackground(background: string, fallback: string) {
const value = background.trim() || fallback
if (value.startsWith('http')) {
return `linear-gradient(135deg, color-mix(in srgb, ${fallback} 78%, transparent), color-mix(in srgb, ${fallback} 34%, transparent)), url("${value.replaceAll('"', '%22')}") center / cover`
}
if (value.startsWith('linear-gradient') || value.startsWith('radial-gradient')) {
return value
}
return `linear-gradient(135deg, ${value}, color-mix(in srgb, ${value} 58%, white))`
}

View File

@@ -1,6 +1,6 @@
import type { CSSProperties } from 'react'
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
import { IconDashboard, IconEmail, IconPlus } from '@arco-design/web-react/icon'
import { IconCompass, IconDashboard, IconPlus } from '@arco-design/web-react/icon'
import type { Project, WorkbenchView } from './project-types'
const { Sider } = Layout
@@ -10,7 +10,7 @@ export function ProjectRail({
projects,
activeView,
onSelectWorkspace,
onSelectWorkspaceInbox,
onSelectWorkspaceExplore,
onSelectProject,
onCreateProject,
}: {
@@ -18,7 +18,7 @@ export function ProjectRail({
projects: Project[]
activeView: WorkbenchView
onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void
onSelectWorkspaceExplore: () => void
onSelectProject: (project: Project) => void
onCreateProject: () => void
}) {
@@ -32,11 +32,11 @@ export function ProjectRail({
title="工作台"
/>
<Button
className={activeView === 'workspace-inbox' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconEmail />}
onClick={onSelectWorkspaceInbox}
aria-label="Inbox"
title="Inbox"
className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconCompass />}
onClick={onSelectWorkspaceExplore}
aria-label="探索"
title="探索"
/>
<Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => (

View File

@@ -1,9 +1,20 @@
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
import { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
import type { ChannelKey, ProjectWorkspace, WorkbenchView } from './project-types'
import { Fragment, useEffect, useState } from 'react'
import { Badge, Button, Divider, Input, Layout, Modal, Space, Tag, Typography } from '@arco-design/web-react'
import { IconApps, IconDashboard, IconMore, IconPlus, IconSettings } from '@arco-design/web-react/icon'
import type { ChannelKey, ProjectChannel, ProjectWorkspace, WorkbenchView } from './project-types'
const { Sider } = Layout
const { Title, Text } = Typography
const { TextArea } = Input
export type ProjectSettingsUpdate = {
projectId: string
name: string
identifier: string
icon: string
background: string
description: string
}
export function ProjectSidebar({
activeView,
@@ -11,24 +22,57 @@ export function ProjectSidebar({
activeChannel,
onSelectChannel,
onSelectItem,
onUpdateProject,
onCreateProjectTag,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
activeChannel: ChannelKey
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
onCreateProjectTag: (name: string) => void
}) {
const workspaceMode = activeView === 'workspace'
const channels = activeWorkspace.channels
const [settingsOpen, setSettingsOpen] = useState(false)
const [draft, setDraft] = useState({
name: activeWorkspace.project.name,
identifier: activeWorkspace.project.identifier,
icon: activeWorkspace.project.icon,
background: activeWorkspace.project.background,
description: activeWorkspace.project.description,
})
const [tagModalOpen, setTagModalOpen] = useState(false)
const [tagName, setTagName] = useState('')
useEffect(() => {
if (!settingsOpen) return
setDraft({
name: activeWorkspace.project.name,
identifier: activeWorkspace.project.identifier,
icon: activeWorkspace.project.icon,
background: activeWorkspace.project.background,
description: activeWorkspace.project.description,
})
}, [activeWorkspace.project, settingsOpen])
const projectIcon = activeWorkspace.project.short || activeWorkspace.project.identifier || activeWorkspace.project.name.slice(0, 2)
const submitTag = () => {
if (!tagName.trim()) return
onCreateProjectTag(tagName.trim())
setTagName('')
setTagModalOpen(false)
}
return (
<Sider className="channel-sidebar" width={248}>
<div className="project-title">
<Space>
{workspaceMode ? <IconApps /> : <IconDashboard />}
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
</Space>
<Button icon={<IconSettings />} size="mini" />
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
</div>
<div className="channel-list">
@@ -44,16 +88,24 @@ export function ProjectSidebar({
</button>
</>
) : (
channels.map((channel) => (
<button
key={channel.id}
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel(channel.key)}
>
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
{channel.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />}
</button>
))
<>
{channels.flatMap((channel) => {
const items = [renderChannelButton(channel, activeChannel, onSelectChannel)]
if (channel.key === 'cron') {
items.push(
<button
key="new-channel"
className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel('new-channel')}
>
<span className="channel-left"><IconPlus /><Text></Text></span>
<IconMore />
</button>,
)
}
return items
})}
</>
)}
</div>
@@ -62,7 +114,20 @@ export function ProjectSidebar({
<Text className="section-title"></Text>
<Space wrap>
{activeWorkspace.tags.map((tag) => (
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
<Fragment key={tag}>
<Tag color={isAllTag(tag) ? 'arcoblue' : 'gray'}>{tag}</Tag>
{isAllTag(tag) && (
<Button
className="tag-create-button"
size="mini"
type="text"
icon={<IconPlus />}
onClick={() => setTagModalOpen(true)}
>
</Button>
)}
</Fragment>
))}
</Space>
</section>
@@ -78,6 +143,86 @@ export function ProjectSidebar({
))}
</section>
<Modal
className="action-modal"
title="编辑项目"
visible={settingsOpen}
onCancel={() => setSettingsOpen(false)}
onOk={() => {
onUpdateProject({
projectId: activeWorkspace.project.id,
name: draft.name.trim() || activeWorkspace.project.name,
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
setSettingsOpen(false)
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input value={draft.name} onChange={(name) => setDraft((value) => ({ ...value, name }))} />
</label>
<label>
<Text></Text>
<Input value={draft.identifier} onChange={(identifier) => setDraft((value) => ({ ...value, identifier }))} />
</label>
<label>
<Text></Text>
<Input value={draft.icon} onChange={(icon) => setDraft((value) => ({ ...value, icon }))} />
</label>
<label>
<Text></Text>
<Input value={draft.background} onChange={(background) => setDraft((value) => ({ ...value, background }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} value={draft.description} onChange={(description) => setDraft((value) => ({ ...value, description }))} />
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建标签"
visible={tagModalOpen}
onCancel={() => {
setTagModalOpen(false)
setTagName('')
}}
onOk={submitTag}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input
autoFocus
placeholder="例如:设计、客户、重要"
value={tagName}
onChange={setTagName}
onPressEnter={submitTag}
/>
</label>
</Space>
</Modal>
</Sider>
)
}
function isAllTag(tag: string) {
return tag === '全部' || tag === 'all'
}
function renderChannelButton(channel: ProjectChannel, activeChannel: ChannelKey, onSelectChannel: (key: ChannelKey) => void) {
return (
<button
key={channel.id}
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel(channel.key)}
>
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
{channel.key === 'overview' ? null : channel.count !== undefined ? <Badge className="channel-badge-muted" count={channel.count} maxCount={999} /> : <IconMore />}
</button>
)
}

View File

@@ -2,12 +2,16 @@ import type { ReactElement } from 'react'
export type Screen = 'login' | 'workbench'
export type Theme = 'light' | 'dark'
export type WorkbenchView = 'workspace' | 'workspace-inbox' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
export type Project = {
id: string
name: string
identifier: string
icon: string
background: string
description: string
short: string
badge: number
urgent?: boolean

View File

@@ -1,4 +1,5 @@
import { Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import {
IconCalendar,
IconCheckCircleFill,
@@ -6,14 +7,44 @@ import {
IconFile,
IconRobot,
} from '@arco-design/web-react/icon'
import type { Project, ProjectWorkspace } from './projects/project-types'
import type { Project, ProjectWorkspace, TaskItem } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
const { TextArea } = Input
export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectWorkspace[]; onOpenTask: (project: Project, taskID: string) => void }) {
export type WorkspaceTaskUpdate = {
originalProjectId: string
nextProjectId: string
taskId: string
title: string
summary: string
tag: string
completed: boolean
}
type WorkspaceTask = TaskItem & {
project: Project
}
type TaskDraft = {
title: string
summary: string
projectId: string
tag: string
completed: boolean
}
export function WorkspacePage({
workspaces,
onUpdateTask,
}: {
workspaces: ProjectWorkspace[]
onOpenTask: (project: Project, taskID: string) => void
onUpdateTask: (update: WorkspaceTaskUpdate) => void
}) {
const projects = workspaces.map((workspace) => workspace.project)
const unfinishedTasks = workspaces.flatMap((workspace) =>
const unfinishedTasks: WorkspaceTask[] = workspaces.flatMap((workspace) =>
workspace.tasks
.filter((task) => !task.completed)
.map((task) => ({
@@ -21,6 +52,28 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
project: workspace.project,
})),
)
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
const tagOptions = useMemo(() => {
const tags = new Set<string>()
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
selectedWorkspace?.tags
.filter((tag) => tag !== 'all' && tag !== '全部')
.forEach((tag) => tags.add(tag))
if (draft.tag) tags.add(draft.tag)
return Array.from(tags)
}, [draft.projectId, draft.tag, workspaces])
useEffect(() => {
if (!editingTask) return
setDraft({
title: editingTask.title,
summary: editingTask.summary,
projectId: editingTask.project.id,
tag: editingTask.tag,
completed: editingTask.completed,
})
}, [editingTask])
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
const notes = workspaces.flatMap((workspace) => workspace.notes)
const urgentProjects = projects.filter((project) => project.urgent).length
@@ -65,7 +118,7 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
{unfinishedTasks.length ? (
<div className="workspace-task-list">
{unfinishedTasks.map((task) => (
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => onOpenTask(task.project, task.id)}>
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => setEditingTask(task)}>
<div className="workspace-task-main">
<span className="workspace-task-title">
<IconCheckCircleFill />
@@ -87,6 +140,62 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
<Empty description="暂无未完成任务" />
)}
</Card>
<Modal
className="action-modal"
title="编辑任务"
visible={Boolean(editingTask)}
onCancel={() => setEditingTask(null)}
onOk={() => {
if (!editingTask) return
onUpdateTask({
originalProjectId: editingTask.project.id,
nextProjectId: draft.projectId,
taskId: editingTask.id,
title: draft.title.trim() || editingTask.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
setEditingTask(null)
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} value={draft.summary} onChange={(summary) => setDraft((value) => ({ ...value, summary }))} />
</label>
<label>
<Text></Text>
<Select value={draft.projectId} onChange={(projectId) => setDraft((value) => ({ ...value, projectId }))}>
{projects.map((project) => (
<Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>
))}
</Select>
</label>
<label>
<Text></Text>
<Select
allowCreate
value={draft.tag}
placeholder="选择或输入标签"
onChange={(tag) => setDraft((value) => ({ ...value, tag }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
<label className="action-switch">
<Text></Text>
<Switch checked={draft.completed} checkedText="完成" uncheckedText="未完成" onChange={(completed) => setDraft((value) => ({ ...value, completed }))} />
</label>
</Space>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconBook, IconCompass, IconLink, IconLoop, IconRefresh, IconStar } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
const rssSources = [
{ name: '产品更新', url: 'https://example.com/product.xml', status: '运行中', count: 18, color: 'arcoblue' },
{ name: '行业情报', url: 'https://example.com/market.xml', status: '运行中', count: 24, color: 'green' },
{ name: '技术博客', url: 'https://example.com/engineering.xml', status: '待同步', count: 9, color: 'orange' },
]
export function WorkspaceExplorePage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
const articles = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
const selected = articles[0]
const pendingCount = articles.filter((item) => item.status === 'open').length
const sourceCount = rssSources.length
return (
<div className="workspace-explore-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary"> RSS 线</Text>
</div>
<Space>
<Button icon={<IconRefresh />}> RSS</Button>
<Button type="primary" icon={<IconLink />}></Button>
</Space>
</div>
<Row gutter={12}>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="arcoblue">{sourceCount}</Tag><Text>RSS </Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="red">{pendingCount}</Tag><Text></Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="green">{articles.length}</Tag><Text></Text></Space>
</Card>
</Col>
</Row>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>RSS </Title>
<Button type="text" size="mini"></Button>
</div>
{rssSources.map((source) => (
<button className="data-row rss-source-row" key={source.url} onClick={() => onSelectItem(source.name)}>
<span className="row-title"><IconLink /> {source.name}</span>
<Tag color={source.color}>{source.status}</Tag>
<span>{source.url}</span>
<span>{source.count} </span>
</button>
))}
</Card>
{articles.length ? (
<div className="mail-layout">
<Card className="mail-list queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{articles.map((item, index) => (
<button
className={index === 0 ? 'mail-item active' : 'mail-item'}
key={`${item.project.id}-${item.id}`}
onClick={() => onSelectItem(item.title)}
>
<span><IconCompass /> {item.title}</span>
<Text type="secondary">{item.meta}</Text>
<div>
<Tag color={item.project.urgent ? 'red' : 'arcoblue'}>{item.project.name}</Tag>
<time>{item.time}</time>
</div>
</button>
))}
</Card>
<Card className="mail-detail queue-section" bordered>
<div className="section-header">
<Title heading={6}>{selected.title}</Title>
<Space>
<Button type="text" icon={<IconStar />} />
<Button type="text" icon={<IconLoop />} />
</Space>
</div>
<Text type="secondary">{selected.meta} · {selected.project.name}</Text>
<Paragraph>{selected.summary || '暂无采集正文'}</Paragraph>
<div className="reply-box">
<Text type="secondary"></Text>
<Space>
<Button icon={<IconBook />}></Button>
<Button></Button>
<Button type="primary"></Button>
</Space>
</div>
</Card>
</div>
) : (
<Card className="queue-section" bordered>
<Empty description="暂无 RSS 采集条目" />
</Card>
)}
</div>
)
}

View File

@@ -1,9 +1,9 @@
import { Layout } from '@arco-design/web-react'
import { WorkspacePage } from './workspace-body'
import { WorkspaceInboxPage } from './workspace-inbox'
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
import { WorkspaceExplorePage } from './workspace-explore'
import { ProjectChannelPage } from './projects/project-channel-page'
import { ProjectRail } from './projects/project-rail'
import { ProjectSidebar } from './projects/project-sidebar'
import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-sidebar'
import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
@@ -19,7 +19,7 @@ export function ProjectPage({
theme,
onSelectProject,
onSelectWorkspace,
onSelectWorkspaceInbox,
onSelectWorkspaceExplore,
onSelectChannel,
onSelectItem,
onOpenTask,
@@ -29,6 +29,9 @@ export function ProjectPage({
onCreateTask,
onUploadSource,
onCreateCronPlan,
onUpdateWorkspaceTask,
onUpdateProject,
onCreateProjectTag,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
@@ -38,7 +41,7 @@ export function ProjectPage({
theme: Theme
onSelectProject: (project: Project) => void
onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void
onSelectWorkspaceExplore: () => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onOpenTask: (project: Project, taskID: string) => void
@@ -48,6 +51,9 @@ export function ProjectPage({
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
onCreateProjectTag: (name: string) => void
}) {
const isProject = activeView === 'project'
const projects = workspaces.map((workspace) => workspace.project)
@@ -62,7 +68,7 @@ export function ProjectPage({
projects={projects}
activeView={activeView}
onSelectWorkspace={onSelectWorkspace}
onSelectWorkspaceInbox={onSelectWorkspaceInbox}
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
onSelectProject={onSelectProject}
onCreateProject={onCreateProject}
/>
@@ -74,14 +80,16 @@ export function ProjectPage({
activeChannel={activeChannel}
onSelectChannel={onSelectChannel}
onSelectItem={onSelectItem}
onUpdateProject={onUpdateProject}
onCreateProjectTag={onCreateProjectTag}
/>
)}
<Content className="stage">
{activeView === 'workspace' ? (
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} />
) : activeView === 'workspace-inbox' ? (
<WorkspaceInboxPage workspaces={workspaces} onSelectItem={onSelectItem} />
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
) : activeView === 'workspace-explore' ? (
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
) : (
<ProjectChannelPage
activeChannel={activeChannel}

View File

@@ -1,95 +0,0 @@
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArchive, IconEmail, IconRefresh, IconSend, IconStar } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
export function WorkspaceInboxPage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
const messages = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
const selected = messages[0]
const openCount = messages.filter((item) => item.status === 'open').length
const handledCount = messages.length - openCount
return (
<div className="workspace-inbox-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Inbox</Title>
<Text type="secondary">线</Text>
</div>
<Space>
<Button icon={<IconRefresh />}></Button>
<Button type="primary" icon={<IconArchive />}></Button>
</Space>
</div>
<Row gutter={12}>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="red">{openCount}</Tag><Text></Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="arcoblue">{messages.length}</Tag><Text></Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="green">{handledCount}</Tag><Text></Text></Space>
</Card>
</Col>
</Row>
{messages.length ? (
<div className="mail-layout">
<Card className="mail-list queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{messages.map((item, index) => (
<button
className={index === 0 ? 'mail-item active' : 'mail-item'}
key={`${item.project.id}-${item.id}`}
onClick={() => onSelectItem(item.title)}
>
<span><IconEmail /> {item.title}</span>
<Text type="secondary">{item.meta}</Text>
<div>
<Tag color={item.project.urgent ? 'red' : 'arcoblue'}>{item.project.name}</Tag>
<time>{item.time}</time>
</div>
</button>
))}
</Card>
<Card className="mail-detail queue-section" bordered>
<div className="section-header">
<Title heading={6}>{selected.title}</Title>
<Space>
<Button type="text" icon={<IconStar />} />
<Button type="text" icon={<IconSend />} />
</Space>
</div>
<Text type="secondary">{selected.meta} · {selected.project.name}</Text>
<Paragraph>{selected.summary || '暂无消息正文'}</Paragraph>
<div className="reply-box">
<Text type="secondary"></Text>
<Space>
<Button></Button>
<Button></Button>
<Button type="primary"></Button>
</Space>
</div>
</Card>
</div>
) : (
<Card className="queue-section" bordered>
<Empty description="暂无外部消息" />
</Card>
)}
</div>
)
}

View File

@@ -29,9 +29,12 @@ func (h *Handler) Register(router gin.IRouter) {
router.GET("/projects", h.listProjects)
router.GET("/projects/:id/dashboard", h.dashboard)
router.GET("/projects/:id/workspace", h.workspace)
router.GET("/projects/:id/tags", h.listTags)
router.POST("/projects/:id/tasks", h.createTask)
router.PATCH("/projects/:id/tasks/:taskID", h.updateTask)
router.POST("/projects/:id/sources", h.uploadSource)
router.POST("/projects/:id/cron-plans", h.createCronPlan)
router.POST("/projects/:id/tags", h.createTag)
}
func (h *Handler) createProject(c *gin.Context) {
@@ -42,13 +45,22 @@ func (h *Handler) createProject(c *gin.Context) {
}
var input struct {
Name string `json:"name"`
Identifier string `json:"identifier"`
Icon string `json:"icon"`
Background string `json:"background"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
project, err := h.service.CreateProject(userID, input.Name, input.Description)
project, err := h.service.CreateProjectWithInput(userID, CreateProjectInput{
Name: input.Name,
Identifier: input.Identifier,
Icon: input.Icon,
Background: input.Background,
Description: input.Description,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -108,6 +120,24 @@ func (h *Handler) workspace(c *gin.Context) {
c.JSON(http.StatusOK, workspace)
}
func (h *Handler) listTags(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
tags, err := h.service.ListProjectTags(userID, projectID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tags)
}
func (h *Handler) createTask(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
@@ -123,6 +153,7 @@ func (h *Handler) createTask(c *gin.Context) {
Description string `json:"description"`
Status string `json:"status"`
DueAt string `json:"dueAt"`
Tag string `json:"tag"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
@@ -138,6 +169,7 @@ func (h *Handler) createTask(c *gin.Context) {
Description: input.Description,
Status: input.Status,
DueAt: dueAt,
Tag: input.Tag,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -146,6 +178,47 @@ func (h *Handler) createTask(c *gin.Context) {
c.JSON(http.StatusCreated, task)
}
func (h *Handler) updateTask(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
taskID, ok := parseTaskID(c)
if !ok {
return
}
var input struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Completed bool `json:"completed"`
NextProjectID uint `json:"nextProjectId"`
Tag string `json:"tag"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
task, err := h.service.UpdateTask(userID, projectID, taskID, UpdateTaskInput{
Title: input.Title,
Description: input.Description,
Status: input.Status,
Completed: input.Completed,
NextProjectID: input.NextProjectID,
Tag: input.Tag,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, task)
}
func (h *Handler) uploadSource(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
@@ -233,6 +306,31 @@ func (h *Handler) createCronPlan(c *gin.Context) {
c.JSON(http.StatusCreated, plan)
}
func (h *Handler) createTag(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
var input struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
tag, err := h.service.CreateProjectTag(userID, projectID, input.Name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, tag)
}
func parseProjectID(c *gin.Context) (uint, bool) {
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@@ -242,6 +340,15 @@ func parseProjectID(c *gin.Context) (uint, bool) {
return uint(projectID), true
}
func parseTaskID(c *gin.Context) (uint, bool) {
taskID, err := strconv.ParseUint(c.Param("taskID"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
return 0, false
}
return uint(taskID), true
}
func parseOptionalTime(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {

View File

@@ -17,9 +17,35 @@ import (
"senlinai-agent/backend/internal/models"
)
func TestCreateProjectHandlerPersistsMetadata(t *testing.T) {
router, _, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{
"name": "Explore",
"identifier": "EXP",
"icon": "compass",
"background": "#165DFF",
"description": "RSS exploration workspace",
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var project models.SenlinAgentProject
require.NoError(t, models.DBService.Where("owner_id = ? AND identifier = ?", 1, "EXP").First(&project).Error)
require.Equal(t, "Explore", project.Name)
require.Equal(t, "compass", project.Icon)
require.Equal(t, "#165DFF", project.Background)
require.Equal(t, "RSS exploration workspace", project.Description)
}
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z"})
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z", "tag": "需求"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -33,6 +59,7 @@ func TestCreateTaskHandlerPersistsTask(t *testing.T) {
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt)
require.NotNil(t, task.TagID)
}
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
@@ -79,6 +106,60 @@ func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
require.NotNil(t, plan.NextRunAt)
}
func TestCreateTagHandlerPersistsProjectTag(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{"name": "Design"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tags", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var tag models.SenlinAgentTag
require.NoError(t, models.DBService.Where("project_id = ? AND name = ?", project.ID, "Design").First(&tag).Error)
require.Equal(t, project.ID, tag.ProjectID)
}
func TestListTagsHandlerReturnsProjectTags(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
require.NoError(t, models.DBService.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "Design"}).Error)
req := httptest.NewRequest(http.MethodGet, "/api/projects/1/tags", nil)
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var tags []models.SenlinAgentTag
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &tags))
require.Len(t, tags, 1)
require.Equal(t, "Design", tags[0].Name)
}
func TestUpdateTaskHandlerPersistsTagAndStatus(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Draft", Status: "open"}
require.NoError(t, models.DBService.Create(&task).Error)
body, err := json.Marshal(gin.H{"title": "Draft v2", "description": "Updated", "completed": true, "tag": "Important"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPatch, "/api/projects/1/tasks/1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var updated models.SenlinAgentTask
require.NoError(t, models.DBService.First(&updated, task.ID).Error)
require.Equal(t, "Draft v2", updated.Title)
require.Equal(t, "done", updated.Status)
require.NotNil(t, updated.TagID)
}
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
t.Helper()
newTestDB(t)

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
@@ -23,6 +24,9 @@ type Dashboard struct {
type WorkbenchProject struct {
ID uint `json:"id"`
Name string `json:"name"`
Identifier string `json:"identifier"`
Icon string `json:"icon"`
Background string `json:"background"`
Description string `json:"description"`
Initials string `json:"initials"`
UnreadCount int64 `json:"unreadCount"`
@@ -99,11 +103,29 @@ type ProjectWorkspace struct {
CronPlans []CronPlan `json:"cronPlans"`
}
type CreateProjectInput struct {
Name string
Identifier string
Icon string
Background string
Description string
}
type CreateTaskInput struct {
Title string
Description string
Status string
DueAt *time.Time
Tag string
}
type UpdateTaskInput struct {
Title string
Description string
Status string
Completed bool
NextProjectID uint
Tag string
}
type CreateFileSourceInput struct {
@@ -123,11 +145,26 @@ func NewService() *Service {
}
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
name = strings.TrimSpace(name)
return s.CreateProjectWithInput(ownerID, CreateProjectInput{Name: name, Description: description})
}
func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectInput) (*models.SenlinAgentProject, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, errors.New("project name is required")
}
project := &models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description}
identifier := strings.TrimSpace(input.Identifier)
if identifier == "" {
identifier = projectInitials(name)
}
project := &models.SenlinAgentProject{
OwnerID: ownerID,
Name: name,
Identifier: identifier,
Icon: strings.TrimSpace(input.Icon),
Background: strings.TrimSpace(input.Background),
Description: strings.TrimSpace(input.Description),
}
return project, models.DBService.Create(project).Error
}
@@ -149,9 +186,14 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
if status == "" {
status = "open"
}
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
if err != nil {
return nil, err
}
task := &models.SenlinAgentTask{
ProjectID: projectID,
CreatedBy: ownerID,
TagID: tagID,
Title: title,
Description: strings.TrimSpace(input.Description),
Status: status,
@@ -160,6 +202,58 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
return task, models.DBService.Create(task).Error
}
func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
var task models.SenlinAgentTask
if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil {
return nil, err
}
nextProjectID := input.NextProjectID
if nextProjectID == 0 {
nextProjectID = task.ProjectID
}
if err := ensureProjectOwner(ownerID, nextProjectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("task title is required")
}
tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag)
if err != nil {
return nil, err
}
projectIdentity, err := projectIdentity(nextProjectID)
if err != nil {
return nil, err
}
tagIdentity, err := optionalTagIdentity(tagID)
if err != nil {
return nil, err
}
status := strings.TrimSpace(input.Status)
if status == "" {
if input.Completed {
status = "done"
} else {
status = "open"
}
}
task.ProjectID = nextProjectID
task.ProjectIdentity = projectIdentity
task.Title = title
task.Description = strings.TrimSpace(input.Description)
task.Status = status
task.TagID = tagID
task.TagIdentity = tagIdentity
if err := models.DBService.Save(&task).Error; err != nil {
return nil, err
}
return &task, nil
}
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
@@ -206,21 +300,55 @@ func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronP
return plan, models.DBService.Create(plan).Error
}
func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
return s.CreateTag(projectID, name)
}
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("tag name is required")
}
var existing models.SenlinAgentTag
err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error
if err == nil {
return &existing, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
return tag, models.DBService.Create(tag).Error
}
func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
return s.ListTags(projectID)
}
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
var tags []models.SenlinAgentTag
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
return tags, err
}
func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, nil
}
tag, err := s.CreateTag(projectID, name)
if err != nil {
return nil, err
}
return &tag.ID, nil
}
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return Dashboard{}, err
@@ -284,8 +412,11 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
Project: WorkbenchProject{
ID: project.ID,
Name: project.Name,
Identifier: project.Identifier,
Icon: project.Icon,
Background: project.Background,
Description: project.Description,
Initials: projectInitials(project.Name),
Initials: defaultString(project.Identifier, projectInitials(project.Name)),
UnreadCount: counts.inbox,
},
Channels: channels,
@@ -409,6 +540,10 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
return nil, err
}
tagNames, err := taskTagNames(tasks)
if err != nil {
return nil, err
}
items := make([]WorkTask, 0, len(tasks))
for _, task := range tasks {
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
@@ -423,12 +558,58 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
Owner: owner,
Due: displayOptionalTime(task.DueAt),
CreatedAt: displayTime(task.CreatedAt),
Tag: "",
Tag: tagNames[task.ID],
})
}
return items, nil
}
func taskTagNames(tasks []models.SenlinAgentTask) (map[uint]string, error) {
names := make(map[uint]string)
tagIDs := make([]uint, 0)
for _, task := range tasks {
if task.TagID != nil {
tagIDs = append(tagIDs, *task.TagID)
}
}
if len(tagIDs) == 0 {
return names, nil
}
var tags []models.SenlinAgentTag
if err := models.DBService.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil {
return nil, err
}
tagByID := make(map[uint]string, len(tags))
for _, tag := range tags {
tagByID[tag.ID] = tag.Name
}
for _, task := range tasks {
if task.TagID != nil {
names[task.ID] = tagByID[*task.TagID]
}
}
return names, nil
}
func projectIdentity(projectID uint) (string, error) {
var project models.SenlinAgentProject
if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil {
return "", err
}
return project.Identity, nil
}
func optionalTagIdentity(tagID *uint) (*string, error) {
if tagID == nil {
return nil, nil
}
var tag models.SenlinAgentTag
if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil {
return nil, err
}
return &tag.Identity, nil
}
func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, error) {
var sessions []models.SenlinAgentAISession
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {

View File

@@ -30,6 +30,33 @@ func TestProjectTagsAreScopedToProject(t *testing.T) {
require.Equal(t, first.ID, firstTags[0].ProjectID)
}
func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProjectWithInput(1, CreateProjectInput{
Name: "探索项目",
Identifier: "EXP",
Icon: "compass",
Background: "#165DFF",
Description: "RSS 采集和线索沉淀",
})
require.NoError(t, err)
require.Equal(t, "探索项目", project.Name)
require.Equal(t, "EXP", project.Identifier)
require.Equal(t, "compass", project.Icon)
require.Equal(t, "#165DFF", project.Background)
require.Equal(t, "RSS 采集和线索沉淀", project.Description)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Equal(t, "EXP", workspace.Project.Identifier)
require.Equal(t, "compass", workspace.Project.Icon)
require.Equal(t, "#165DFF", workspace.Project.Background)
require.Equal(t, "RSS 采集和线索沉淀", workspace.Project.Description)
}
func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
@@ -140,6 +167,7 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
Description: "整理客户反馈并形成行动项",
Status: "",
DueAt: &dueAt,
Tag: "客户反馈",
})
require.NoError(t, err)
@@ -148,10 +176,13 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
require.Equal(t, "Follow up with client", task.Title)
require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt)
require.NotNil(t, task.TagID)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Len(t, workspace.Tasks, 1)
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
require.Equal(t, "客户反馈", workspace.Tasks[0].Tag)
require.Equal(t, []string{"all", "客户反馈"}, workspace.Tags)
}
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
@@ -165,6 +196,58 @@ func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
require.Error(t, err)
}
func TestUpdateTaskPersistsTagAndCompletion(t *testing.T) {
newTestDB(t)
service := NewService()
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
task, err := service.CreateTask(1, project.ID, CreateTaskInput{Title: "Draft proposal", Tag: "客户"})
require.NoError(t, err)
updated, err := service.UpdateTask(1, project.ID, task.ID, UpdateTaskInput{
Title: "Draft proposal v2",
Description: "Updated scope",
Completed: true,
Tag: "重要",
})
require.NoError(t, err)
require.Equal(t, "Draft proposal v2", updated.Title)
require.Equal(t, "done", updated.Status)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Equal(t, "重要", workspace.Tasks[0].Tag)
require.Equal(t, []string{"all", "客户", "重要"}, workspace.Tags)
}
func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
tag, err := service.CreateProjectTag(1, project.ID, "Design")
require.NoError(t, err)
require.Equal(t, project.ID, tag.ProjectID)
require.Equal(t, "Design", tag.Name)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Equal(t, []string{"all", "Design"}, workspace.Tags)
}
func TestCreateProjectTagRejectsProjectOwnedByAnotherUser(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(2, "Beta", "")
require.NoError(t, err)
_, err = service.CreateProjectTag(1, project.ID, "Hidden")
require.Error(t, err)
}
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()

View File

@@ -41,6 +41,9 @@ func (m *SenlinAgentTask) BeforeCreate(tx *gorm.DB) error {
if err := resolveOptionalIdentity(tx, &SenlinAgentUser{}, m.AssigneeID, &m.AssigneeIdentity); err != nil {
return err
}
if err := resolveOptionalIdentity(tx, &SenlinAgentTag{}, m.TagID, &m.TagIdentity); err != nil {
return err
}
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
}

View File

@@ -8,6 +8,9 @@ type SenlinAgentProject struct {
OwnerID uint `gorm:"index;not null"`
OwnerIdentity string `gorm:"type:char(36);index"`
Name string `gorm:"not null"`
Identifier string `gorm:"index"`
Icon string
Background string
Description string
CreatedAt time.Time
UpdatedAt time.Time

View File

@@ -11,6 +11,8 @@ type SenlinAgentTask struct {
CreatedByIdentity string `gorm:"type:char(36);index"`
AssigneeID *uint `gorm:"index"`
AssigneeIdentity *string `gorm:"type:char(36);index"`
TagID *uint `gorm:"index"`
TagIdentity *string `gorm:"type:char(36);index"`
SourceInboxItemID *uint `gorm:"index"`
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
Title string `gorm:"not null"`

View File

@@ -131,6 +131,18 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
return err
}
}
uiTagID, err := tagID(tx, projectID, "UI")
if err != nil {
return err
}
feedbackTagID, err := tagID(tx, projectID, "客户反馈")
if err != nil {
return err
}
automationTagID, err := tagID(tx, projectID, "自动化")
if err != nil {
return err
}
for _, item := range []models.SenlinAgentInboxItem{
{ProjectID: projectID, CreatedBy: userID, SourceType: "产品研究", Title: "竞争对手定价更新监控", Body: "跟进企业版价格调整和功能组合变化。", Status: "open"},
{ProjectID: projectID, CreatedBy: userID, SourceType: "客户支持", Title: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"},
@@ -142,9 +154,9 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
}
}
for _, task := range []models.SenlinAgentTask{
{ProjectID: projectID, CreatedBy: userID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
{ProjectID: projectID, CreatedBy: userID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
{ProjectID: projectID, CreatedBy: userID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
{ProjectID: projectID, CreatedBy: userID, TagID: &uiTagID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
{ProjectID: projectID, CreatedBy: userID, TagID: &automationTagID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
{ProjectID: projectID, CreatedBy: userID, TagID: &feedbackTagID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
} {
if err := firstOrCreateTask(tx, task); err != nil {
return err
@@ -209,12 +221,31 @@ func firstOrCreateTag(tx *gorm.DB, projectID uint, name string) error {
return firstOrCreate(tx, &models.SenlinAgentTag{}, "project_id = ? AND name = ?", []any{projectID, name}, models.SenlinAgentTag{ProjectID: projectID, Name: name})
}
func tagID(tx *gorm.DB, projectID uint, name string) (uint, error) {
var tag models.SenlinAgentTag
if err := tx.Where("project_id = ? AND name = ?", projectID, name).First(&tag).Error; err != nil {
return 0, err
}
return tag.ID, nil
}
func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error {
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
}
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
return firstOrCreate(tx, &models.SenlinAgentTask{}, "project_id = ? AND title = ?", []any{task.ProjectID, task.Title}, task)
var existing models.SenlinAgentTask
err := tx.Where("project_id = ? AND title = ?", task.ProjectID, task.Title).First(&existing).Error
if err == nil {
if task.TagID != nil && existing.TagID == nil {
return tx.Model(&existing).Update("tag_id", *task.TagID).Error
}
return nil
}
if err != gorm.ErrRecordNotFound {
return err
}
return tx.Create(&task).Error
}
func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error {