fix(workbench): complete profile and channel flows

This commit is contained in:
2026-07-24 14:46:38 +08:00
parent d7eb20a85f
commit f430566976
21 changed files with 563 additions and 151 deletions

View File

@@ -164,6 +164,13 @@ const visualCheckWorkspace = {
cronPlans: [],
}
await page.route('http://agent.senlin.ai/api/v1/status', async (route) => {
await route.fulfill({
json: { timestamp: '2026-07-24T00:00:00Z' },
headers: { 'access-control-allow-origin': '*' },
})
})
await page.route('http://localhost:9150/api/v1/**', async (route) => {
const url = new URL(route.request().url())
if (url.pathname === '/api/v1/status') {
@@ -171,7 +178,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
return
}
if (url.pathname === '/api/v1/auth/login') {
await route.fulfill({ json: { token: 'visual-check-token' } })
await route.fulfill({ json: { token: 'visual-check-token', user: { email: 'visual@senlin.ai', displayName: '视觉检查' } } })
return
}
if (url.pathname === '/api/v1/projects') {
@@ -182,6 +189,16 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
await route.fulfill({ json: visualCheckWorkspace })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/channels` && route.request().method() === 'POST') {
const input = route.request().postDataJSON()
const channel = {
id: '019b0000-0000-7000-8000-000000000060', projectId, type: 'custom_link',
title: input.title, icon: input.icon || 'link', count: 0, url: input.url, sortOrder: 8,
}
visualCheckWorkspace.channels = [...visualCheckWorkspace.channels, channel]
await route.fulfill({ status: 201, json: channel })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/documents` && route.request().method() === 'GET') {
await route.fulfill({ json: [] })
return
@@ -201,7 +218,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
createdAt: '2026-07-22T09:00:00Z',
updatedAt: '2026-07-22T09:00:00Z',
},
})
})
return
}
await route.fulfill({
@@ -394,6 +411,8 @@ const collectMetrics = async () => page.evaluate(() => {
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' })
await page.locator('.login-form input').first().fill('localhost:9150')
await page.locator('.login-form input').nth(1).fill('visual@senlin.ai')
await page.locator('.login-form input').nth(2).fill('password123')
await page.waitForSelector('.connection-card.online')
const loginConnectionStatus = await page.locator('.connection-card').textContent()
await page.screenshot({ path: 'test-results/login-react-acro.png', fullPage: true })
@@ -451,6 +470,7 @@ const channelListHoverMetrics = await collectMetrics()
const channelPageChecks = []
let taskTagFilterCheck = null
let aiSessionCheck = null
let savedChannelVisible = false
for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: '' },
@@ -506,6 +526,15 @@ for (const channel of [
await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true })
}
if (channel.label === '新建频道') {
const inputs = page.locator('.project-new-channel-page input')
await inputs.nth(0).fill('客户研究')
await inputs.nth(2).fill('https://example.com/customer-research')
await page.getByRole('button', { name: '保存频道' }).click()
await page.waitForTimeout(250)
savedChannelVisible = await page.locator('.channel-button', { hasText: '客户研究' }).count() === 1
}
if (channel.label === '笔记资料') {
await page.screenshot({ path: 'test-results/project-documents-light.png', fullPage: true })
}
@@ -533,6 +562,7 @@ console.log(JSON.stringify({
channelPageChecks,
taskTagFilterCheck,
aiSessionCheck,
savedChannelVisible,
errors,
}, null, 2))
@@ -543,10 +573,8 @@ if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
if (metrics.statusbarHeight !== 32) failures.push(`expected statusbar height 32, got ${metrics.statusbarHeight}`)
if (!metrics.statusName) failures.push('expected authenticated status label')
if (!metrics.statusOnlineDot) failures.push('expected legacy online status dot')
if (!metrics.statusUpgrade) failures.push('expected legacy upgrade action')
if (!metrics.statusSystemText.includes('3 个计划进行中') || !metrics.statusSystemText.includes('AI 空闲') || !metrics.statusSystemText.includes('152GB 可用')) {
failures.push(`expected legacy system status summary, got ${JSON.stringify(metrics.statusSystemText)}`)
}
if (metrics.statusUpgrade) failures.push('expected billing entry to be removed from the status bar')
if (!metrics.statusSystemText.includes('已连接到私有工作台')) failures.push(`expected connected status summary, got ${JSON.stringify(metrics.statusSystemText)}`)
if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`)
if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.workbenchMain) failures.push('missing workbench main')
@@ -632,6 +660,7 @@ if (!editSourceModalCheck.title.includes('编辑数据源') || editSourceModalCh
failures.push(`expected prefilled edit data source modal, got ${JSON.stringify(editSourceModalCheck)}`)
}
if (!editedSourceVisible) failures.push('expected edited data source name to update its card')
if (!savedChannelVisible) failures.push('expected saved custom channel to appear in the project sidebar')
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) {
@@ -699,8 +728,8 @@ for (const check of channelPageChecks) {
if (!check.activeChannel.includes(check.label)) {
failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`)
}
if (check.label === '新建频道' && (check.inputCount < 4 || !check.hasSaveChannel)) {
failures.push(`expected legacy new channel editor, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`)
if (check.label === '新建频道' && (check.inputCount < 3 || !check.hasSaveChannel)) {
failures.push(`expected channel editor with persisted link fields, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`)
}
if (check.label === 'AI 会话' && (
check.hasOverviewHead || !check.hasSessionList || !check.hasComposer || !check.hasExpertPicker ||

View File

@@ -60,6 +60,26 @@
color: var(--senlin-muted);
}
/* Arco's input wrappers and select views keep light defaults unless they are
explicitly themed; cover the complete form surface used by project settings. */
.theme-dark .arco-input-wrapper,
.theme-dark .arco-input-inner-wrapper,
.theme-dark .arco-select-view,
.theme-dark .arco-textarea-wrapper,
.theme-dark .arco-picker,
.theme-dark .arco-input,
.theme-dark .arco-textarea {
background: var(--senlin-panel);
color: var(--senlin-text);
border-color: var(--senlin-border);
}
.theme-dark .arco-input::placeholder,
.theme-dark .arco-textarea::placeholder,
.theme-dark .arco-select-view-value {
color: var(--senlin-muted);
}
.login-screen {
min-height: 100vh;
display: grid;

View File

@@ -3,6 +3,11 @@ export type ApiSession = {
token: string
}
export type CurrentUser = {
email: string
displayName: string
}
type RequestOptions = {
method?: string
body?: unknown
@@ -40,12 +45,19 @@ export function getApiBaseUrl() {
return configuredBaseUrl
}
export async function login(email: string, password: string): Promise<ApiSession> {
const response = await apiRequest<{ token: string }>('/api/v1/auth/login', {
export async function login(email: string, password: string): Promise<ApiSession & { user: CurrentUser }> {
const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', {
method: 'POST',
body: { email, password },
})
return { baseUrl: configuredBaseUrl, token: response.token }
return { baseUrl: configuredBaseUrl, token: response.token, user: response.user }
}
export function updateCurrentUser(
session: ApiSession,
input: { displayName: string; currentPassword?: string; newPassword?: string },
) {
return apiRequest<CurrentUser>('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input })
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {

View File

@@ -141,6 +141,12 @@ export type CreateProjectInput = {
export type UpdateProjectInput = Partial<CreateProjectInput>
export type CreateProjectChannelInput = {
title: string
icon?: string
url: string
}
export type CreateTaskInput = {
title: string
description?: string
@@ -197,6 +203,14 @@ export async function updateProject(session: ApiSession, projectId: string, inpu
})
}
export async function createProjectChannel(session: ApiSession, projectId: string, input: CreateProjectChannelInput) {
return apiRequest<WorkspaceChannelDTO>(`/api/v1/projects/${projectId}/channels`, {
method: 'POST',
token: session.token,
body: input,
})
}
export async function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) {
return apiRequest<TaskDTO>(`/api/v1/projects/${projectId}/tasks`, {
method: 'POST',

View File

@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react'
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
import '@arco-design/web-react/dist/css/arco.css'
import '../App.css'
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
import { ApiError, login, setApiBaseUrl, updateCurrentUser, type ApiSession, type CurrentUser } from '../api/client'
import { createAISession, listAIExperts, listAISessions, type CreateAISessionInput } from '../api/ai'
import {
createDatasetSource,
@@ -20,6 +20,8 @@ import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
import { mapWorkspace } from '../api/mappers'
import {
createCronPlan,
createProjectChannel,
createProject,
createProjectTag,
createTask,
@@ -44,6 +46,7 @@ function App() {
const [theme, setTheme] = useState<Theme>('light')
const [activeView, setActiveView] = useState<WorkbenchView>('workspace')
const [session, setSession] = useState<ApiSession | null>(null)
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
const [activeProjectID, setActiveProjectID] = useState<string>('')
const activeProjectIDRef = useRef('')
@@ -163,9 +166,11 @@ function App() {
setLoading(true)
try {
setApiBaseUrl(input.server)
const nextSession = await login(input.email, input.password)
const loginResult = await login(input.email, input.password)
const { user, ...nextSession } = loginResult
await loadWorkspaces(nextSession, '')
setSession(nextSession)
setCurrentUser(user)
selectActiveProject('')
setActiveChannel('overview')
setActiveView('workspace')
@@ -177,6 +182,23 @@ function App() {
}
}
async function handleUpdateProfile(input: { displayName: string; currentPassword?: string; newPassword?: string }) {
const updated = await updateCurrentUser(requireSession(), input)
setCurrentUser(updated)
Message.success('个人资料已更新')
}
function handleLogout() {
setSession(null)
setCurrentUser(null)
setWorkspaces([])
selectActiveProject('')
setActiveView('workspace')
setActiveChannel('overview')
setActiveTaskID(null)
setScreen('login')
}
async function refreshAfterAction(nextProjectID?: string) {
if (!session) return
await loadWorkspaces(session, nextProjectID)
@@ -283,6 +305,13 @@ function App() {
}, '标签已创建')
}
async function handleCreateProjectChannel(input: { title: string; icon: string; url: string }) {
const projectID = requireActiveProject()
await createProjectChannel(requireSession(), projectID, input)
await refreshAfterAction(projectID)
setActiveChannel('overview')
}
function openTask(project: Project, taskID: string) {
selectActiveProject(project.id)
setActiveView('project')
@@ -390,7 +419,7 @@ function App() {
<Spin loading={loading} style={{ width: '100%' }}>
<LoginPage onLogin={handleLogin} />
</Spin>
) : session ? (
) : session && currentUser ? (
<ProjectPage
activeView={activeView}
activeWorkspace={activeWorkspace}
@@ -425,6 +454,10 @@ function App() {
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
onUpdateProject={handleUpdateProject}
onCreateProjectTag={handleCreateProjectTag}
currentUser={currentUser}
onUpdateProfile={handleUpdateProfile}
onLogout={handleLogout}
onCreateProjectChannel={handleCreateProjectChannel}
searchQuery={workspaceSearch.query}
searchLoading={workspaceSearch.loading}
searchSearched={workspaceSearch.searched}

View File

@@ -17,9 +17,9 @@ const { Title, Text, Paragraph } = Typography
type ConnectionStatus = 'checking' | 'online' | 'offline'
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
const [server, setServer] = useState('http://localhost:9150')
const [email, setEmail] = useState('demo@senlin.ai')
const [password, setPassword] = useState('password123')
const [server, setServer] = useState('agent.senlin.ai')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [status, setStatus] = useState<ConnectionStatus>('checking')
useEffect(() => {

View File

@@ -50,7 +50,7 @@ export function ProjectAi({
setSelectedExpertID(null)
setExpertQuery('')
setExpertCategory('all')
setPrompt('')
setPrompt(readPromptDraft(projectId))
setLoading(true)
setLoadingExperts(true)
setCreating(false)
@@ -91,6 +91,15 @@ export function ProjectAi({
}
}, [onListExperts, onListSessions, projectId])
useEffect(() => {
try {
if (prompt) localStorage.setItem(promptDraftKey(projectId), prompt)
else localStorage.removeItem(promptDraftKey(projectId))
} catch {
// Draft persistence is best effort; an unavailable browser store must not block chat.
}
}, [projectId, prompt])
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionID) ?? null,
[selectedSessionID, sessions],
@@ -345,3 +354,15 @@ function sessionTitle(message: string) {
const characters = Array.from(firstLine)
return characters.length > 36 ? `${characters.slice(0, 36).join('')}` : firstLine
}
function promptDraftKey(projectId: string) {
return `senlin:ai:prompt:${projectId}`
}
function readPromptDraft(projectId: string) {
try {
return localStorage.getItem(promptDraftKey(projectId)) ?? ''
} catch {
return ''
}
}

View File

@@ -1,3 +1,5 @@
import { Button, Card, Typography } from '@arco-design/web-react'
import { IconLaunch } from '@arco-design/web-react/icon'
import { ProjectAi } from './project-ai'
import { ProjectCron } from './project-cron'
import { ProjectInbox } from './project-inbox'
@@ -32,6 +34,7 @@ export function ProjectChannelPage({
onListAISessions,
onListAIExperts,
onCreateAISession,
onCreateProjectChannel,
}: {
activeChannel: ChannelKey
activeWorkspace: ProjectWorkspace
@@ -52,7 +55,12 @@ export function ProjectChannelPage({
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
onCreateProjectChannel: (input: { title: string; icon: string; url: string }) => Promise<void>
}) {
if (activeChannel.startsWith('custom:')) {
const channel = activeWorkspace.channels.find((item) => item.key === activeChannel)
return channel ? <CustomLinkChannel label={channel.label} url={channel.url} /> : <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUpdateTask={onUpdateTask} />
}
switch (activeChannel) {
case 'inbox':
return <ProjectInbox key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onAnalyze={onAnalyzeInbox} onConfirm={onConfirmInbox} />
@@ -74,9 +82,22 @@ export function ProjectChannelPage({
case 'cron':
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
case 'new-channel':
return <ProjectNewChannel activeWorkspace={activeWorkspace} />
return <ProjectNewChannel activeWorkspace={activeWorkspace} onCreate={onCreateProjectChannel} />
case 'overview':
default:
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUpdateTask={onUpdateTask} />
}
}
function CustomLinkChannel({ label, url }: { label: string; url?: string }) {
const { Title, Text } = Typography
return (
<div className="project-channel-page overview-page">
<Card className="queue-section" bordered>
<Title heading={4}>{label}</Title>
<Text type="secondary"></Text>
{url ? <Button type="primary" icon={<IconLaunch />} onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}></Button> : null}
</Card>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon'
import { Button, Card, Empty, Space, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
@@ -27,7 +27,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<Tag color="gray">{pausedCount} </Tag>
</Space>
</div>
{cronJobs.map((job) => (
{cronJobs.length === 0 ? <Empty description="暂无计划任务" /> : cronJobs.map((job) => (
<div
className="data-row cron-row"
key={job.name}
@@ -44,17 +44,9 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<span>{job.lastRun}</span>
<span>{job.nextRun}</span>
<span>{job.owner}</span>
<Switch size="small" checked={job.enabled} />
</div>
))}
</Card>
<Card className="queue-section cron-summary" bordered>
<Space>
<Tag color="arcoblue"><IconThunderbolt /></Tag>
<Text> 12:00</Text>
</Space>
</Card>
</div>
)
}

View File

@@ -33,8 +33,6 @@ import ReactMarkdown from 'react-markdown'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import DOMPurify from 'dompurify'
import mammoth from 'mammoth'
import { readSheet as readXlsxSheet } from 'read-excel-file/browser'
import type { ApiSession } from '../../api/client'
import {
createDocumentFolder,
@@ -79,6 +77,8 @@ type UploadItem = {
error?: string
}
type PersistedDocumentDraft = Pick<DocumentTab, 'key' | 'documentId' | 'parentId' | 'name' | 'extension' | 'mimeType' | 'markdown' | 'revision' | 'status' | 'mode'>
export function ProjectDocuments({
session,
projectId,
@@ -167,17 +167,20 @@ export function ProjectDocuments({
void refreshTree().then(async (items) => {
if (cancelled) return
const stored = readStoredTabs(projectId)
const drafts = readStoredDrafts(projectId)
const draftByDocumentID = new Map(drafts.filter((draft) => draft.documentId).map((draft) => [draft.documentId!, draft]))
const restored: DocumentTab[] = []
for (const id of stored) {
const item = items.find((document) => document.id === id && document.kind === 'file')
if (!item) continue
try {
const detail = await getDocument(session, projectId, id)
if (!cancelled) restored.push(tabFromDocument(detail))
if (!cancelled) restored.push(restoreDocumentDraft(tabFromDocument(detail), draftByDocumentID.get(id)))
} catch {
// Missing tabs are discarded.
}
}
restored.push(...drafts.filter((draft) => !draft.documentId))
if (cancelled) return
if (restored.length > 0) {
setTabs(restored)
@@ -201,6 +204,8 @@ export function ProjectDocuments({
useEffect(() => {
const ids = tabs.flatMap((tab) => tab.documentId ? [tab.documentId] : [])
localStorage.setItem(storageKey(projectId), JSON.stringify(ids))
const drafts = tabs.filter((tab) => tab.status === 'dirty' || tab.status === 'saving' || tab.status === 'error')
localStorage.setItem(draftStorageKey(projectId), JSON.stringify(drafts))
}, [projectId, tabs])
useEffect(() => {
@@ -689,10 +694,12 @@ function SmartPreview({ session, projectId, tab }: { session: ApiSession; projec
if (isText(extension, tab.mimeType)) {
setText(await blob.text())
} else if (extension === '.docx') {
const result = await mammoth.convertToHtml({ arrayBuffer: await blob.arrayBuffer() })
const mammoth = await import('mammoth')
const result = await mammoth.default.convertToHtml({ arrayBuffer: await blob.arrayBuffer() })
if (active) setHtml(DOMPurify.sanitize(result.value))
} else if (extension === '.xlsx') {
const parsed = await readXlsxSheet(blob)
const { readSheet } = await import('read-excel-file/browser')
const parsed = await readSheet(blob)
if (active) setRows(parsed as unknown[][])
} else {
const previewBlob = extension === '.svg'
@@ -793,6 +800,10 @@ function storageKey(projectId: string) {
return `senlin:documents:tabs:${projectId}`
}
function draftStorageKey(projectId: string) {
return `senlin:documents:drafts:${projectId}`
}
function readStoredTabs(projectId: string): string[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(storageKey(projectId)) ?? '[]')
@@ -802,6 +813,32 @@ function readStoredTabs(projectId: string): string[] {
}
}
function readStoredDrafts(projectId: string): PersistedDocumentDraft[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(draftStorageKey(projectId)) ?? '[]')
if (!Array.isArray(value)) return []
return value.filter(isPersistedDocumentDraft)
} catch {
return []
}
}
function isPersistedDocumentDraft(value: unknown): value is PersistedDocumentDraft {
if (!value || typeof value !== 'object') return false
const draft = value as Partial<PersistedDocumentDraft>
return typeof draft.key === 'string' && typeof draft.name === 'string' && typeof draft.extension === 'string' &&
typeof draft.mimeType === 'string' && typeof draft.markdown === 'string' && typeof draft.revision === 'number' &&
(draft.status === 'dirty' || draft.status === 'saving' || draft.status === 'error') &&
(draft.mode === 'edit' || draft.mode === 'preview') &&
(draft.documentId === undefined || typeof draft.documentId === 'string') &&
(draft.parentId === undefined || typeof draft.parentId === 'string')
}
function restoreDocumentDraft(tab: DocumentTab, draft?: PersistedDocumentDraft): DocumentTab {
if (!draft) return tab
return { ...tab, markdown: draft.markdown, revision: draft.revision, status: draft.status === 'saving' ? 'dirty' : draft.status, mode: draft.mode }
}
function saveLabel(status: SaveStatus) {
return { clean: '已保存', dirty: '未保存', saving: '保存中', saved: '已保存', error: '保存失败' }[status]
}

View File

@@ -1,61 +1,74 @@
import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
import { useState } from 'react'
import { Button, Card, Input, Message, 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 }) {
export function ProjectNewChannel({
activeWorkspace,
onCreate,
}: {
activeWorkspace: ProjectWorkspace
onCreate: (input: { title: string; icon: string; url: string }) => Promise<void>
}) {
const [title, setTitle] = useState('')
const [icon, setIcon] = useState('link')
const [url, setURL] = useState('')
const [saving, setSaving] = useState(false)
async function save() {
if (!title.trim() || !url.trim()) {
Message.warning('请填写频道名称和链接地址')
return
}
setSaving(true)
try {
await onCreate({ title: title.trim(), icon: icon.trim(), url: url.trim() })
setTitle('')
setIcon('link')
setURL('')
Message.success('频道已保存')
} catch (error) {
Message.error(error instanceof Error ? error.message : '频道保存失败')
} finally {
setSaving(false)
}
}
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>
<Text type="secondary"> {activeWorkspace.project.name} </Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
<Button type="primary" icon={<IconPlus />} loading={saving} onClick={() => void save()}></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>
<Input value={title} onChange={setTitle} placeholder="例如:客户研究频道" />
</label>
<label>
<Text></Text>
<Input placeholder="例如link / user / file" prefix={<IconApps />} />
<Input value={icon} onChange={setIcon} 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="频道用途、维护规则或采集说明" />
<Input value={url} onChange={setURL} placeholder="https://example.com/channel" prefix={<IconLink />} />
</label>
</Space>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="gray"></Tag>
<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>
<Text type="secondary"> Agent</Text>
</Card>
</div>
)

View File

@@ -1,47 +1,56 @@
import { useState } from 'react'
import { Avatar, Button, Card, Form, Input, Layout, Modal, Radio, Space, Typography } from '@arco-design/web-react'
import {
IconApps,
IconCalendar,
IconRobot,
IconStorage,
IconUser,
} from '@arco-design/web-react/icon'
import { useEffect, useState } from 'react'
import { Avatar, Button, Form, Input, Layout, Modal, Space, Typography } from '@arco-design/web-react'
import { IconUser } from '@arco-design/web-react/icon'
import type { CurrentUser } from '../../api/client'
const { Footer } = Layout
const { Text, Title } = Typography
const { Text } = Typography
const plans = [
{ id: 'starter', name: '基础版', price: '¥29/月', desc: '个人项目、基础智能体与 20GB 存储。' },
{ id: 'pro', name: '专业版', price: '¥99/月', desc: '团队协作、高级智能体与 200GB 存储。' },
{ id: 'team', name: '团队版', price: '¥299/月', desc: '成员管理、审计日志与私有化部署支持。' },
]
export function ProjectStatusbar() {
export function ProjectStatusbar({
user,
onUpdateProfile,
onLogout,
}: {
user: CurrentUser
onUpdateProfile: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise<void>
onLogout: () => void
}) {
const [profileOpen, setProfileOpen] = useState(false)
const [upgradeOpen, setUpgradeOpen] = useState(false)
const [selectedPlan, setSelectedPlan] = useState('pro')
const [displayName, setDisplayName] = useState(user.displayName)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
setDisplayName(user.displayName)
}, [user.displayName])
async function saveProfile() {
setSaving(true)
try {
await onUpdateProfile({ displayName, currentPassword, newPassword })
setCurrentPassword('')
setNewPassword('')
setProfileOpen(false)
} finally {
setSaving(false)
}
}
return (
<>
<Footer className="statusbar">
<div className="status-user">
<button className="status-profile" type="button" onClick={() => setProfileOpen(true)}>
<Avatar size={24} style={{ backgroundColor: '#165DFF' }}></Avatar>
<Avatar size={24} style={{ backgroundColor: '#165DFF' }}>{user.displayName.slice(0, 1) || <IconUser />}</Avatar>
<span className="status-identity">
<Text className="status-name"></Text>
<span className="status-online-dot" aria-label="在线" title="在线" />
<Text className="status-name">{user.displayName}</Text>
<span className="status-online-dot" aria-label="已登录" title="已登录" />
</span>
</button>
<Button className="status-upgrade" size="mini" type="primary" onClick={() => setUpgradeOpen(true)}></Button>
</div>
<Space className="status-system" size={18}>
<span><IconInteractionFallback /> 3 </span>
<span><IconRobot /> AI </span>
<span><IconStorage /> 152GB </span>
<span> <b></b></span>
<IconApps />
<Space className="status-system" size={12}>
<span></span>
</Space>
</Footer>
@@ -49,66 +58,36 @@ export function ProjectStatusbar() {
title="个人资料"
visible={profileOpen}
onCancel={() => setProfileOpen(false)}
onOk={() => setProfileOpen(false)}
onOk={() => void saveProfile()}
confirmLoading={saving}
okText="保存"
cancelText="取消"
footer={(cancel, ok) => (
<Space>
<Button status="danger" onClick={onLogout}>退</Button>
{cancel}
{ok}
</Space>
)}
>
<Form layout="vertical" className="status-modal-form">
<Form.Item label="头像">
<Space>
<Avatar size={42} style={{ backgroundColor: '#165DFF' }}></Avatar>
<Button icon={<IconUser />}></Button>
</Space>
<Avatar size={42} style={{ backgroundColor: '#165DFF' }}>{user.displayName.slice(0, 1) || <IconUser />}</Avatar>
</Form.Item>
<Form.Item label="名称">
<Input defaultValue="张明" placeholder="请输入名称" />
<Form.Item label="名称" required>
<Input value={displayName} onChange={setDisplayName} placeholder="请输入名称" />
</Form.Item>
<Form.Item label="邮箱">
<Input defaultValue="zhangming@senlin.ai" placeholder="请输入邮箱" />
<Input value={user.email} disabled />
</Form.Item>
<Form.Item label="当前密码">
<Input.Password placeholder="用于确认身份" />
<Input.Password value={currentPassword} onChange={setCurrentPassword} placeholder="修改密码时必填" />
</Form.Item>
<Form.Item label="新密码">
<Input.Password placeholder="不修改密码可留空" />
<Input.Password value={newPassword} onChange={setNewPassword} placeholder="至少 8 个字符;不修改可留空" />
</Form.Item>
</Form>
</Modal>
<Modal
title="升级套餐"
visible={upgradeOpen}
onCancel={() => setUpgradeOpen(false)}
onOk={() => setUpgradeOpen(false)}
okText="去支付"
cancelText="取消"
>
<Radio.Group value={selectedPlan} onChange={setSelectedPlan} className="billing-plan-group">
{plans.map((plan) => (
<Card className={selectedPlan === plan.id ? 'billing-plan active' : 'billing-plan'} key={plan.id} bordered>
<Radio value={plan.id}>
<div>
<Title heading={6}>{plan.name}</Title>
<Text className="billing-price">{plan.price}</Text>
<Text type="secondary">{plan.desc}</Text>
</div>
</Radio>
</Card>
))}
</Radio.Group>
<div className="billing-payments">
<Text type="secondary"></Text>
<Space>
<Button></Button>
<Button></Button>
<Button></Button>
</Space>
</div>
</Modal>
</>
)
}
function IconInteractionFallback() {
return <IconCalendar />
}

View File

@@ -20,7 +20,7 @@ import type {
DatasetSyncResultDTO,
} from '../api/dataset'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
import type { ApiSession } from '../api/client'
import type { ApiSession, CurrentUser } from '../api/client'
import type { DocumentOpenIntent } from '../api/documents'
const { Content } = Layout
@@ -69,6 +69,10 @@ export function ProjectPage({
onUpdateDatasetItem,
onSyncDatasetSources,
onDepositDatasetItem,
currentUser,
onUpdateProfile,
onLogout,
onCreateProjectChannel,
}: {
activeView: WorkbenchView
activeWorkspace?: ProjectWorkspace
@@ -113,6 +117,10 @@ export function ProjectPage({
onUpdateDatasetItem: (itemId: string, input: DatasetItemUpdate) => Promise<DatasetItemDTO>
onSyncDatasetSources: () => Promise<DatasetSyncResultDTO[]>
onDepositDatasetItem: (itemId: string, projectId: string) => Promise<DatasetDepositDTO & { refreshFailed?: boolean }>
currentUser: CurrentUser
onUpdateProfile: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise<void>
onLogout: () => void
onCreateProjectChannel: (input: { title: string; icon: string; url: string }) => Promise<void>
}) {
const isProject = activeView === 'project' && Boolean(activeWorkspace)
const projects = workspaces.map((workspace) => workspace.project)
@@ -196,6 +204,7 @@ export function ProjectPage({
onCreateTask={onCreateTask}
onCreateCronPlan={onCreateCronPlan}
onCreateProjectTag={onCreateProjectTag}
onCreateProjectChannel={onCreateProjectChannel}
onUpdateTask={updateActiveProjectTask}
onAnalyzeInbox={onAnalyzeInbox}
onConfirmInbox={onConfirmInbox}
@@ -210,7 +219,7 @@ export function ProjectPage({
</Layout>
<ProjectStatusbar />
<ProjectStatusbar user={currentUser} onUpdateProfile={onUpdateProfile} onLogout={onLogout} />
</Layout>
)
}

View File

@@ -39,6 +39,7 @@ func main() {
taskService := tasks.NewService(models.DBService)
projectHandler := projects.NewHandler(projectService)
tagHandler := projects.NewTagHandler(projectService)
channelHandler := projects.NewChannelHandler(projectService)
cronHandler := projects.NewCronHandler(projectService)
taskHandler := tasks.NewHandler(taskService)
documentHandler := documents.NewHandler(documentService, cfg.MaxUploadBytes)
@@ -54,6 +55,7 @@ func main() {
datasetHandler,
projectHandler,
tagHandler,
channelHandler,
cronHandler,
taskHandler,
documentHandler,

View File

@@ -1,9 +1,12 @@
package auth
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
type Handler struct {
@@ -16,6 +19,8 @@ func NewHandler(service *Service) *Handler {
func (h *Handler) Register(router gin.IRouter) {
router.POST("/auth/login", h.login)
router.GET("/auth/me", h.me)
router.PATCH("/auth/me", h.updateMe)
}
func (h *Handler) login(c *gin.Context) {
@@ -32,5 +37,72 @@ func (h *Handler) login(c *gin.Context) {
abortWithError(c, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
userID, err := h.service.VerifySession(token)
if err != nil {
abortWithError(c, http.StatusInternalServerError, "internal_error", "登录状态创建失败")
return
}
user, err := h.service.CurrentUser(userID)
if err != nil {
abortWithError(c, http.StatusInternalServerError, "internal_error", "用户资料加载失败")
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "user": currentUserDTO(*user)})
}
type CurrentUserDTO struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
}
func (h *Handler) me(c *gin.Context) {
userID, ok := CurrentUserID(c)
if !ok {
abortWithError(c, http.StatusUnauthorized, "unauthorized", "请先登录后再操作")
return
}
user, err := h.service.CurrentUser(userID)
if err != nil {
writeProfileError(c, err)
return
}
c.JSON(http.StatusOK, currentUserDTO(*user))
}
func (h *Handler) updateMe(c *gin.Context) {
userID, ok := CurrentUserID(c)
if !ok {
abortWithError(c, http.StatusUnauthorized, "unauthorized", "请先登录后再操作")
return
}
var input struct {
DisplayName string `json:"displayName"`
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err := c.ShouldBindJSON(&input); err != nil {
abortWithError(c, http.StatusBadRequest, "invalid_request", "个人资料参数无效")
return
}
user, err := h.service.UpdateCurrentUser(userID, input.DisplayName, input.CurrentPassword, input.NewPassword)
if err != nil {
writeProfileError(c, err)
return
}
c.JSON(http.StatusOK, currentUserDTO(*user))
}
func currentUserDTO(user models.SaUser) CurrentUserDTO {
return CurrentUserDTO{Email: user.Email, DisplayName: user.DisplayName}
}
func writeProfileError(c *gin.Context, err error) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
abortWithError(c, http.StatusNotFound, "not_found", "用户不存在")
case errors.Is(err, ErrProfileNameRequired), errors.Is(err, ErrPasswordTooShort), errors.Is(err, ErrCurrentPassword):
abortWithError(c, http.StatusBadRequest, "invalid_request", err.Error())
default:
abortWithError(c, http.StatusInternalServerError, "internal_error", "个人资料更新失败")
}
}

View File

@@ -21,6 +21,12 @@ type Service struct {
sessionTTL time.Duration
}
var (
ErrProfileNameRequired = errors.New("display name is required")
ErrCurrentPassword = errors.New("current password is incorrect")
ErrPasswordTooShort = errors.New("new password must be at least 8 characters")
)
func NewService(secret string) *Service {
return &Service{secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour}
}
@@ -86,6 +92,46 @@ func (s *Service) VerifySession(token string) (uint, error) {
return userID, nil
}
// CurrentUser returns the authenticated user's profile without exposing password hashes.
func (s *Service) CurrentUser(userID uint) (*models.SaUser, error) {
var user models.SaUser
if err := models.DBService.Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// UpdateCurrentUser updates the small MVP profile surface. A password change always
// requires the current password so a stolen unlocked session cannot silently rotate it.
func (s *Service) UpdateCurrentUser(userID uint, displayName, currentPassword, newPassword string) (*models.SaUser, error) {
user, err := s.CurrentUser(userID)
if err != nil {
return nil, err
}
name := strings.TrimSpace(displayName)
if name == "" {
return nil, ErrProfileNameRequired
}
updates := map[string]any{"display_name": name}
if strings.TrimSpace(newPassword) != "" {
if len(newPassword) < 8 {
return nil, ErrPasswordTooShort
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
return nil, ErrCurrentPassword
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
updates["password_hash"] = string(hash)
}
if err := models.DBService.Model(user).Updates(updates).Error; err != nil {
return nil, err
}
return s.CurrentUser(userID)
}
type tokenPayload struct {
Type string `json:"type"`
Subject string `json:"subject"`

View File

@@ -139,7 +139,10 @@ func (h *Handler) create(c *gin.Context, folder bool) {
input := CreateInput{Name: request.Name, ParentIdentity: request.ParentID, Markdown: request.Markdown}
if request.SourceInboxItemID != "" {
var inbox models.SaInboxItem
if err := h.service.database().Select("id").Where("identity = ?", request.SourceInboxItemID).First(&inbox).Error; err != nil {
if err := h.service.database().Select("id").Where(
"identity = ? AND project_id = ? AND created_by = ?",
request.SourceInboxItemID, projectID, userID,
).First(&inbox).Error; err != nil {
writeError(c, gorm.ErrRecordNotFound)
return
}

View File

@@ -0,0 +1,51 @@
package projects
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"senlinai-agent/backend/internal/httpx"
)
// ChannelHandler owns project-scoped custom-link writes.
type ChannelHandler struct {
service *Service
}
func NewChannelHandler(service *Service) *ChannelHandler {
return &ChannelHandler{service: service}
}
func (h *ChannelHandler) Register(router gin.IRouter) {
router.POST("/projects/:id/channels", h.create)
}
func (h *ChannelHandler) create(c *gin.Context) {
userID, project, ok := ownedProjectFromRequest(c)
if !ok {
return
}
var input CreateProjectChannelInput
if err := c.ShouldBindJSON(&input); err != nil {
httpx.Error(c, http.StatusBadRequest, "invalid_request", "频道参数无效")
return
}
channel, err := h.service.CreateProjectChannel(userID, project.ID, input)
if err != nil {
switch {
case errors.Is(err, ErrChannelTitleRequired), errors.Is(err, ErrChannelURLRequired), errors.Is(err, ErrChannelURLInvalid):
httpx.Error(c, http.StatusBadRequest, "invalid_request", "频道名称和链接地址不能为空")
case errors.Is(err, gorm.ErrRecordNotFound):
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在")
default:
httpx.Error(c, http.StatusInternalServerError, "internal_error", "频道创建失败")
}
return
}
c.JSON(http.StatusCreated, WorkspaceChannelDTO{
ID: channel.Identity, ProjectID: project.Identity, Type: "custom_link", Title: channel.Title,
Icon: channel.Icon, URL: channel.URL, SortOrder: channel.SortOrder,
})
}

View File

@@ -41,17 +41,23 @@ type CreateCronPlanInput struct {
NextRunAt *time.Time
}
type CreateProjectChannelInput struct {
Title string `json:"title"`
Icon string `json:"icon"`
URL string `json:"url"`
}
// WorkspaceDTO 是项目工作区首屏聚合响应,所有数据库对象均以公开 identity 关联。
type WorkspaceDTO struct {
Project WorkspaceProjectDTO `json:"project"`
Channels []WorkspaceChannelDTO `json:"channels"`
Tags []WorkspaceTagDTO `json:"tags"`
RecentSessions []WorkspaceAISessionDTO `json:"recentSessions"`
Inbox []WorkspaceInboxDTO `json:"inbox"`
Tasks []WorkspaceTaskDTO `json:"tasks"`
AISessions []WorkspaceAISessionDTO `json:"aiSessions"`
Documents []WorkspaceDocumentDTO `json:"documents"`
CronPlans []WorkspaceCronPlanDTO `json:"cronPlans"`
Project WorkspaceProjectDTO `json:"project"`
Channels []WorkspaceChannelDTO `json:"channels"`
Tags []WorkspaceTagDTO `json:"tags"`
RecentSessions []WorkspaceAISessionDTO `json:"recentSessions"`
Inbox []WorkspaceInboxDTO `json:"inbox"`
Tasks []WorkspaceTaskDTO `json:"tasks"`
AISessions []WorkspaceAISessionDTO `json:"aiSessions"`
Documents []WorkspaceDocumentDTO `json:"documents"`
CronPlans []WorkspaceCronPlanDTO `json:"cronPlans"`
}
// WorkspaceProjectDTO 补充工作区导航所需的项目摘要和未读计数。

View File

@@ -2,6 +2,7 @@ package projects
import (
"errors"
"net/url"
"strings"
"gorm.io/gorm"
@@ -64,8 +65,40 @@ var (
ErrProjectNameRequired = errors.New("project name is required")
ErrProjectIdentifierRequired = errors.New("project identifier is required")
ErrProjectIdentifierConflict = errors.New("project identifier already exists")
ErrChannelTitleRequired = errors.New("channel title is required")
ErrChannelURLRequired = errors.New("channel url is required")
ErrChannelURLInvalid = errors.New("channel url must use http or https")
)
func (s *Service) CreateProjectChannel(ownerID, projectID uint, input CreateProjectChannelInput) (*models.SaProjectChannel, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, ErrChannelTitleRequired
}
address := strings.TrimSpace(input.URL)
if address == "" {
return nil, ErrChannelURLRequired
}
parsedURL, err := url.ParseRequestURI(address)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
return nil, ErrChannelURLInvalid
}
icon := strings.TrimSpace(input.Icon)
if icon == "" {
icon = "link"
}
var previous models.SaProjectChannel
_ = models.DBService.Where("project_id = ?", projectID).Order("sort_order DESC").First(&previous).Error
channel := &models.SaProjectChannel{ProjectID: projectID, Title: title, Icon: icon, URL: address, SortOrder: previous.SortOrder + 1}
if err := models.DBService.Create(channel).Error; err != nil {
return nil, err
}
return channel, nil
}
// projectWriteError 将 Postgres/SQLite 经 Gorm 翻译后的唯一约束错误收敛为稳定业务冲突。
func projectWriteError(err error) error {
if errors.Is(err, gorm.ErrDuplicatedKey) {

View File

@@ -110,6 +110,25 @@ func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
require.Equal(t, "RSS 采集和线索沉淀", workspace.Project.Description)
}
func TestCreateProjectChannelRequiresOwnedProjectAndSafeURL(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
channel, err := service.CreateProjectChannel(1, project.ID, CreateProjectChannelInput{
Title: "Research", URL: "https://example.com/research",
})
require.NoError(t, err)
require.Equal(t, "link", channel.Icon)
require.Equal(t, 1, channel.SortOrder)
_, err = service.CreateProjectChannel(1, project.ID, CreateProjectChannelInput{Title: "Unsafe", URL: "javascript:alert(1)"})
require.ErrorIs(t, err, ErrChannelURLInvalid)
_, err = service.CreateProjectChannel(2, project.ID, CreateProjectChannelInput{Title: "Foreign", URL: "https://example.com"})
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestWorkspaceMatchesFrontendContract(t *testing.T) {
database := newTestDB(t)
service := NewService()