diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index 6d22c84..9c608bf 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -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 || diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index 9f21214..2a360ac 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -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; diff --git a/apps/web_v1/src/api/client.ts b/apps/web_v1/src/api/client.ts index deccf24..b0935de 100644 --- a/apps/web_v1/src/api/client.ts +++ b/apps/web_v1/src/api/client.ts @@ -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 { - const response = await apiRequest<{ token: string }>('/api/v1/auth/login', { +export async function login(email: string, password: string): Promise { + 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('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input }) } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { diff --git a/apps/web_v1/src/api/projects.ts b/apps/web_v1/src/api/projects.ts index db41549..2a86ff8 100644 --- a/apps/web_v1/src/api/projects.ts +++ b/apps/web_v1/src/api/projects.ts @@ -141,6 +141,12 @@ export type CreateProjectInput = { export type UpdateProjectInput = Partial +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(`/api/v1/projects/${projectId}/channels`, { + method: 'POST', + token: session.token, + body: input, + }) +} + export async function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) { return apiRequest(`/api/v1/projects/${projectId}/tasks`, { method: 'POST', diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 35226c9..c79a29f 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -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('light') const [activeView, setActiveView] = useState('workspace') const [session, setSession] = useState(null) + const [currentUser, setCurrentUser] = useState(null) const [workspaces, setWorkspaces] = useState([]) const [activeProjectID, setActiveProjectID] = useState('') 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() { - ) : session ? ( + ) : session && currentUser ? ( 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('checking') useEffect(() => { diff --git a/apps/web_v1/src/pages/projects/project-ai.tsx b/apps/web_v1/src/pages/projects/project-ai.tsx index 05c6a08..670c5d9 100644 --- a/apps/web_v1/src/pages/projects/project-ai.tsx +++ b/apps/web_v1/src/pages/projects/project-ai.tsx @@ -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 '' + } +} diff --git a/apps/web_v1/src/pages/projects/project-channel-page.tsx b/apps/web_v1/src/pages/projects/project-channel-page.tsx index c761580..c17bbef 100644 --- a/apps/web_v1/src/pages/projects/project-channel-page.tsx +++ b/apps/web_v1/src/pages/projects/project-channel-page.tsx @@ -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 onListAIExperts: (signal?: AbortSignal) => Promise onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise + onCreateProjectChannel: (input: { title: string; icon: string; url: string }) => Promise }) { + if (activeChannel.startsWith('custom:')) { + const channel = activeWorkspace.channels.find((item) => item.key === activeChannel) + return channel ? : + } switch (activeChannel) { case 'inbox': return @@ -74,9 +82,22 @@ export function ProjectChannelPage({ case 'cron': return case 'new-channel': - return + return case 'overview': default: return } } + +function CustomLinkChannel({ label, url }: { label: string; url?: string }) { + const { Title, Text } = Typography + return ( +
+ + {label} + 此频道保存为项目内的外部链接入口。 + {url ? : null} + +
+ ) +} diff --git a/apps/web_v1/src/pages/projects/project-cron.tsx b/apps/web_v1/src/pages/projects/project-cron.tsx index e33853e..2589d7c 100644 --- a/apps/web_v1/src/pages/projects/project-cron.tsx +++ b/apps/web_v1/src/pages/projects/project-cron.tsx @@ -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 }: {pausedCount} 暂停 - {cronJobs.map((job) => ( + {cronJobs.length === 0 ? : cronJobs.map((job) => (
{job.lastRun} {job.nextRun} {job.owner} -
))} - - - - - 最近一次自动任务完成于今天 12:00,资料索引刷新成功,无异常重试。 - - ) } diff --git a/apps/web_v1/src/pages/projects/project-documents.tsx b/apps/web_v1/src/pages/projects/project-documents.tsx index ba9919d..4ccdb4c 100644 --- a/apps/web_v1/src/pages/projects/project-documents.tsx +++ b/apps/web_v1/src/pages/projects/project-documents.tsx @@ -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 + 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 + 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] } diff --git a/apps/web_v1/src/pages/projects/project-new-channel.tsx b/apps/web_v1/src/pages/projects/project-new-channel.tsx index 51f8032..abc9ba8 100644 --- a/apps/web_v1/src/pages/projects/project-new-channel.tsx +++ b/apps/web_v1/src/pages/projects/project-new-channel.tsx @@ -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 +}) { + 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 (
新建频道 - {activeWorkspace.project.name} 的频道入口配置,用于接入外部页面、资料集合或项目流程。 + 为 {activeWorkspace.project.name} 添加项目内的外部链接入口。
- +
- -