fix(workbench): complete profile and channel flows
This commit is contained in:
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user