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: [], 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) => { await page.route('http://localhost:9150/api/v1/**', async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
if (url.pathname === '/api/v1/status') { if (url.pathname === '/api/v1/status') {
@@ -171,7 +178,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
return return
} }
if (url.pathname === '/api/v1/auth/login') { 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 return
} }
if (url.pathname === '/api/v1/projects') { 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 }) await route.fulfill({ json: visualCheckWorkspace })
return 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') { if (url.pathname === `/api/v1/projects/${projectId}/documents` && route.request().method() === 'GET') {
await route.fulfill({ json: [] }) await route.fulfill({ json: [] })
return return
@@ -394,6 +411,8 @@ const collectMetrics = async () => page.evaluate(() => {
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' }) 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').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') await page.waitForSelector('.connection-card.online')
const loginConnectionStatus = await page.locator('.connection-card').textContent() const loginConnectionStatus = await page.locator('.connection-card').textContent()
await page.screenshot({ path: 'test-results/login-react-acro.png', fullPage: true }) await page.screenshot({ path: 'test-results/login-react-acro.png', fullPage: true })
@@ -451,6 +470,7 @@ const channelListHoverMetrics = await collectMetrics()
const channelPageChecks = [] const channelPageChecks = []
let taskTagFilterCheck = null let taskTagFilterCheck = null
let aiSessionCheck = null let aiSessionCheck = null
let savedChannelVisible = false
for (const channel of [ for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' }, { label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-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 }) 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 === '笔记资料') { if (channel.label === '笔记资料') {
await page.screenshot({ path: 'test-results/project-documents-light.png', fullPage: true }) await page.screenshot({ path: 'test-results/project-documents-light.png', fullPage: true })
} }
@@ -533,6 +562,7 @@ console.log(JSON.stringify({
channelPageChecks, channelPageChecks,
taskTagFilterCheck, taskTagFilterCheck,
aiSessionCheck, aiSessionCheck,
savedChannelVisible,
errors, errors,
}, null, 2)) }, 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.statusbarHeight !== 32) failures.push(`expected statusbar height 32, got ${metrics.statusbarHeight}`)
if (!metrics.statusName) failures.push('expected authenticated status label') if (!metrics.statusName) failures.push('expected authenticated status label')
if (!metrics.statusOnlineDot) failures.push('expected legacy online status dot') if (!metrics.statusOnlineDot) failures.push('expected legacy online status dot')
if (!metrics.statusUpgrade) failures.push('expected legacy upgrade action') if (metrics.statusUpgrade) failures.push('expected billing entry to be removed from the status bar')
if (!metrics.statusSystemText.includes('3 个计划进行中') || !metrics.statusSystemText.includes('AI 空闲') || !metrics.statusSystemText.includes('152GB 可用')) { if (!metrics.statusSystemText.includes('已连接到私有工作台')) failures.push(`expected connected status summary, got ${JSON.stringify(metrics.statusSystemText)}`)
failures.push(`expected legacy system status summary, got ${JSON.stringify(metrics.statusSystemText)}`)
}
if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`) if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`)
if (metrics.overflowX) failures.push('expected no horizontal overflow') if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.workbenchMain) failures.push('missing workbench main') 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)}`) 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 (!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) { if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) {
failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`) 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) { } 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)) { if (!check.activeChannel.includes(check.label)) {
failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`) failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`)
} }
if (check.label === '新建频道' && (check.inputCount < 4 || !check.hasSaveChannel)) { if (check.label === '新建频道' && (check.inputCount < 3 || !check.hasSaveChannel)) {
failures.push(`expected legacy new channel editor, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`) failures.push(`expected channel editor with persisted link fields, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`)
} }
if (check.label === 'AI 会话' && ( if (check.label === 'AI 会话' && (
check.hasOverviewHead || !check.hasSessionList || !check.hasComposer || !check.hasExpertPicker || check.hasOverviewHead || !check.hasSessionList || !check.hasComposer || !check.hasExpertPicker ||

View File

@@ -60,6 +60,26 @@
color: var(--senlin-muted); 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 { .login-screen {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;

View File

@@ -3,6 +3,11 @@ export type ApiSession = {
token: string token: string
} }
export type CurrentUser = {
email: string
displayName: string
}
type RequestOptions = { type RequestOptions = {
method?: string method?: string
body?: unknown body?: unknown
@@ -40,12 +45,19 @@ export function getApiBaseUrl() {
return configuredBaseUrl return configuredBaseUrl
} }
export async function login(email: string, password: string): Promise<ApiSession> { export async function login(email: string, password: string): Promise<ApiSession & { user: CurrentUser }> {
const response = await apiRequest<{ token: string }>('/api/v1/auth/login', { const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', {
method: 'POST', method: 'POST',
body: { email, password }, 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> { 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 UpdateProjectInput = Partial<CreateProjectInput>
export type CreateProjectChannelInput = {
title: string
icon?: string
url: string
}
export type CreateTaskInput = { export type CreateTaskInput = {
title: string title: string
description?: 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) { export async function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) {
return apiRequest<TaskDTO>(`/api/v1/projects/${projectId}/tasks`, { return apiRequest<TaskDTO>(`/api/v1/projects/${projectId}/tasks`, {
method: 'POST', method: 'POST',

View File

@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react'
import { ConfigProvider, Message, Spin } from '@arco-design/web-react' import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
import '@arco-design/web-react/dist/css/arco.css' import '@arco-design/web-react/dist/css/arco.css'
import '../App.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 { createAISession, listAIExperts, listAISessions, type CreateAISessionInput } from '../api/ai'
import { import {
createDatasetSource, createDatasetSource,
@@ -20,6 +20,8 @@ import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
import { mapWorkspace } from '../api/mappers' import { mapWorkspace } from '../api/mappers'
import { import {
createCronPlan, createCronPlan,
createProjectChannel,
createProject, createProject,
createProjectTag, createProjectTag,
createTask, createTask,
@@ -44,6 +46,7 @@ function App() {
const [theme, setTheme] = useState<Theme>('light') const [theme, setTheme] = useState<Theme>('light')
const [activeView, setActiveView] = useState<WorkbenchView>('workspace') const [activeView, setActiveView] = useState<WorkbenchView>('workspace')
const [session, setSession] = useState<ApiSession | null>(null) const [session, setSession] = useState<ApiSession | null>(null)
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([]) const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
const [activeProjectID, setActiveProjectID] = useState<string>('') const [activeProjectID, setActiveProjectID] = useState<string>('')
const activeProjectIDRef = useRef('') const activeProjectIDRef = useRef('')
@@ -163,9 +166,11 @@ function App() {
setLoading(true) setLoading(true)
try { try {
setApiBaseUrl(input.server) 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, '') await loadWorkspaces(nextSession, '')
setSession(nextSession) setSession(nextSession)
setCurrentUser(user)
selectActiveProject('') selectActiveProject('')
setActiveChannel('overview') setActiveChannel('overview')
setActiveView('workspace') 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) { async function refreshAfterAction(nextProjectID?: string) {
if (!session) return if (!session) return
await loadWorkspaces(session, nextProjectID) 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) { function openTask(project: Project, taskID: string) {
selectActiveProject(project.id) selectActiveProject(project.id)
setActiveView('project') setActiveView('project')
@@ -390,7 +419,7 @@ function App() {
<Spin loading={loading} style={{ width: '100%' }}> <Spin loading={loading} style={{ width: '100%' }}>
<LoginPage onLogin={handleLogin} /> <LoginPage onLogin={handleLogin} />
</Spin> </Spin>
) : session ? ( ) : session && currentUser ? (
<ProjectPage <ProjectPage
activeView={activeView} activeView={activeView}
activeWorkspace={activeWorkspace} activeWorkspace={activeWorkspace}
@@ -425,6 +454,10 @@ function App() {
onUpdateWorkspaceTask={handleUpdateWorkspaceTask} onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
onUpdateProject={handleUpdateProject} onUpdateProject={handleUpdateProject}
onCreateProjectTag={handleCreateProjectTag} onCreateProjectTag={handleCreateProjectTag}
currentUser={currentUser}
onUpdateProfile={handleUpdateProfile}
onLogout={handleLogout}
onCreateProjectChannel={handleCreateProjectChannel}
searchQuery={workspaceSearch.query} searchQuery={workspaceSearch.query}
searchLoading={workspaceSearch.loading} searchLoading={workspaceSearch.loading}
searchSearched={workspaceSearch.searched} searchSearched={workspaceSearch.searched}

View File

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

View File

@@ -50,7 +50,7 @@ export function ProjectAi({
setSelectedExpertID(null) setSelectedExpertID(null)
setExpertQuery('') setExpertQuery('')
setExpertCategory('all') setExpertCategory('all')
setPrompt('') setPrompt(readPromptDraft(projectId))
setLoading(true) setLoading(true)
setLoadingExperts(true) setLoadingExperts(true)
setCreating(false) setCreating(false)
@@ -91,6 +91,15 @@ export function ProjectAi({
} }
}, [onListExperts, onListSessions, projectId]) }, [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( const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionID) ?? null, () => sessions.find((session) => session.id === selectedSessionID) ?? null,
[selectedSessionID, sessions], [selectedSessionID, sessions],
@@ -345,3 +354,15 @@ function sessionTitle(message: string) {
const characters = Array.from(firstLine) const characters = Array.from(firstLine)
return characters.length > 36 ? `${characters.slice(0, 36).join('')}` : 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 { ProjectAi } from './project-ai'
import { ProjectCron } from './project-cron' import { ProjectCron } from './project-cron'
import { ProjectInbox } from './project-inbox' import { ProjectInbox } from './project-inbox'
@@ -32,6 +34,7 @@ export function ProjectChannelPage({
onListAISessions, onListAISessions,
onListAIExperts, onListAIExperts,
onCreateAISession, onCreateAISession,
onCreateProjectChannel,
}: { }: {
activeChannel: ChannelKey activeChannel: ChannelKey
activeWorkspace: ProjectWorkspace activeWorkspace: ProjectWorkspace
@@ -52,7 +55,12 @@ export function ProjectChannelPage({
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]> onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]> onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO> 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) { switch (activeChannel) {
case 'inbox': case 'inbox':
return <ProjectInbox key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onAnalyze={onAnalyzeInbox} onConfirm={onConfirmInbox} /> return <ProjectInbox key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onAnalyze={onAnalyzeInbox} onConfirm={onConfirmInbox} />
@@ -74,9 +82,22 @@ export function ProjectChannelPage({
case 'cron': case 'cron':
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} /> return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
case 'new-channel': case 'new-channel':
return <ProjectNewChannel activeWorkspace={activeWorkspace} /> return <ProjectNewChannel activeWorkspace={activeWorkspace} onCreate={onCreateProjectChannel} />
case 'overview': case 'overview':
default: default:
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUpdateTask={onUpdateTask} /> 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 { Button, Card, Empty, Space, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon' import { IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types' import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -27,7 +27,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<Tag color="gray">{pausedCount} </Tag> <Tag color="gray">{pausedCount} </Tag>
</Space> </Space>
</div> </div>
{cronJobs.map((job) => ( {cronJobs.length === 0 ? <Empty description="暂无计划任务" /> : cronJobs.map((job) => (
<div <div
className="data-row cron-row" className="data-row cron-row"
key={job.name} key={job.name}
@@ -44,17 +44,9 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<span>{job.lastRun}</span> <span>{job.lastRun}</span>
<span>{job.nextRun}</span> <span>{job.nextRun}</span>
<span>{job.owner}</span> <span>{job.owner}</span>
<Switch size="small" checked={job.enabled} />
</div> </div>
))} ))}
</Card> </Card>
<Card className="queue-section cron-summary" bordered>
<Space>
<Tag color="arcoblue"><IconThunderbolt /></Tag>
<Text> 12:00</Text>
</Space>
</Card>
</div> </div>
) )
} }

View File

@@ -33,8 +33,6 @@ import ReactMarkdown from 'react-markdown'
import rehypeSanitize from 'rehype-sanitize' import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import mammoth from 'mammoth'
import { readSheet as readXlsxSheet } from 'read-excel-file/browser'
import type { ApiSession } from '../../api/client' import type { ApiSession } from '../../api/client'
import { import {
createDocumentFolder, createDocumentFolder,
@@ -79,6 +77,8 @@ type UploadItem = {
error?: string error?: string
} }
type PersistedDocumentDraft = Pick<DocumentTab, 'key' | 'documentId' | 'parentId' | 'name' | 'extension' | 'mimeType' | 'markdown' | 'revision' | 'status' | 'mode'>
export function ProjectDocuments({ export function ProjectDocuments({
session, session,
projectId, projectId,
@@ -167,17 +167,20 @@ export function ProjectDocuments({
void refreshTree().then(async (items) => { void refreshTree().then(async (items) => {
if (cancelled) return if (cancelled) return
const stored = readStoredTabs(projectId) 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[] = [] const restored: DocumentTab[] = []
for (const id of stored) { for (const id of stored) {
const item = items.find((document) => document.id === id && document.kind === 'file') const item = items.find((document) => document.id === id && document.kind === 'file')
if (!item) continue if (!item) continue
try { try {
const detail = await getDocument(session, projectId, id) const detail = await getDocument(session, projectId, id)
if (!cancelled) restored.push(tabFromDocument(detail)) if (!cancelled) restored.push(restoreDocumentDraft(tabFromDocument(detail), draftByDocumentID.get(id)))
} catch { } catch {
// Missing tabs are discarded. // Missing tabs are discarded.
} }
} }
restored.push(...drafts.filter((draft) => !draft.documentId))
if (cancelled) return if (cancelled) return
if (restored.length > 0) { if (restored.length > 0) {
setTabs(restored) setTabs(restored)
@@ -201,6 +204,8 @@ export function ProjectDocuments({
useEffect(() => { useEffect(() => {
const ids = tabs.flatMap((tab) => tab.documentId ? [tab.documentId] : []) const ids = tabs.flatMap((tab) => tab.documentId ? [tab.documentId] : [])
localStorage.setItem(storageKey(projectId), JSON.stringify(ids)) 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]) }, [projectId, tabs])
useEffect(() => { useEffect(() => {
@@ -689,10 +694,12 @@ function SmartPreview({ session, projectId, tab }: { session: ApiSession; projec
if (isText(extension, tab.mimeType)) { if (isText(extension, tab.mimeType)) {
setText(await blob.text()) setText(await blob.text())
} else if (extension === '.docx') { } 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)) if (active) setHtml(DOMPurify.sanitize(result.value))
} else if (extension === '.xlsx') { } 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[][]) if (active) setRows(parsed as unknown[][])
} else { } else {
const previewBlob = extension === '.svg' const previewBlob = extension === '.svg'
@@ -793,6 +800,10 @@ function storageKey(projectId: string) {
return `senlin:documents:tabs:${projectId}` return `senlin:documents:tabs:${projectId}`
} }
function draftStorageKey(projectId: string) {
return `senlin:documents:drafts:${projectId}`
}
function readStoredTabs(projectId: string): string[] { function readStoredTabs(projectId: string): string[] {
try { try {
const value: unknown = JSON.parse(localStorage.getItem(storageKey(projectId)) ?? '[]') 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) { function saveLabel(status: SaveStatus) {
return { clean: '已保存', dirty: '未保存', saving: '保存中', saved: '已保存', error: '保存失败' }[status] 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 { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types' import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography 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 ( return (
<div className="project-channel-page project-new-channel-page overview-page"> <div className="project-channel-page project-new-channel-page overview-page">
<div className="overview-head"> <div className="overview-head">
<div> <div>
<Title heading={4}></Title> <Title heading={4}></Title>
<Text type="secondary">{activeWorkspace.project.name} </Text> <Text type="secondary"> {activeWorkspace.project.name} </Text>
</div> </div>
<Button type="primary" icon={<IconPlus />}></Button> <Button type="primary" icon={<IconPlus />} loading={saving} onClick={() => void save()}></Button>
</div> </div>
<Card className="queue-section channel-editor-card" bordered> <Card className="queue-section channel-editor-card" bordered>
<Space direction="vertical" size={12} className="action-form"> <Space direction="vertical" size={12} className="action-form">
<label> <label>
<Text></Text> <Text></Text>
<Input placeholder="例如:客户研究频道" /> <Input value={title} onChange={setTitle} placeholder="例如:客户研究频道" />
</label>
<label>
<Text></Text>
<Select defaultValue="link">
<Select.Option value="link"></Select.Option>
<Select.Option value="source"></Select.Option>
<Select.Option value="workflow"></Select.Option>
</Select>
</label> </label>
<label> <label>
<Text></Text> <Text></Text>
<Input placeholder="例如link / user / file" prefix={<IconApps />} /> <Input value={icon} onChange={setIcon} placeholder="例如link / user / file" prefix={<IconApps />} />
</label> </label>
<label> <label>
<Text></Text> <Text></Text>
<Input placeholder="https://example.com/channel" prefix={<IconLink />} /> <Input value={url} onChange={setURL} placeholder="https://example.com/channel" prefix={<IconLink />} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="频道用途、维护规则或采集说明" />
</label> </label>
</Space> </Space>
</Card> </Card>
<Card className="queue-section" bordered> <Card className="queue-section" bordered>
<div className="section-header"> <div className="section-header">
<Title heading={6}></Title> <Title heading={6}></Title>
<Tag color="gray"></Tag> <Tag color="gray"></Tag>
</div> </div>
<Space wrap> <Text type="secondary"> Agent</Text>
<Tag color="arcoblue"></Tag>
<Tag color="green"></Tag>
<Tag color="orange">RSS/</Tag>
<Tag color="purple">AI </Tag>
</Space>
</Card> </Card>
</div> </div>
) )

View File

@@ -1,47 +1,56 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Avatar, Button, Card, Form, Input, Layout, Modal, Radio, Space, Typography } from '@arco-design/web-react' import { Avatar, Button, Form, Input, Layout, Modal, Space, Typography } from '@arco-design/web-react'
import { import { IconUser } from '@arco-design/web-react/icon'
IconApps, import type { CurrentUser } from '../../api/client'
IconCalendar,
IconRobot,
IconStorage,
IconUser,
} from '@arco-design/web-react/icon'
const { Footer } = Layout const { Footer } = Layout
const { Text, Title } = Typography const { Text } = Typography
const plans = [ export function ProjectStatusbar({
{ id: 'starter', name: '基础版', price: '¥29/月', desc: '个人项目、基础智能体与 20GB 存储。' }, user,
{ id: 'pro', name: '专业版', price: '¥99/月', desc: '团队协作、高级智能体与 200GB 存储。' }, onUpdateProfile,
{ id: 'team', name: '团队版', price: '¥299/月', desc: '成员管理、审计日志与私有化部署支持。' }, onLogout,
] }: {
user: CurrentUser
export function ProjectStatusbar() { onUpdateProfile: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise<void>
onLogout: () => void
}) {
const [profileOpen, setProfileOpen] = useState(false) const [profileOpen, setProfileOpen] = useState(false)
const [upgradeOpen, setUpgradeOpen] = useState(false) const [displayName, setDisplayName] = useState(user.displayName)
const [selectedPlan, setSelectedPlan] = useState('pro') 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 ( return (
<> <>
<Footer className="statusbar"> <Footer className="statusbar">
<div className="status-user"> <div className="status-user">
<button className="status-profile" type="button" onClick={() => setProfileOpen(true)}> <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"> <span className="status-identity">
<Text className="status-name"></Text> <Text className="status-name">{user.displayName}</Text>
<span className="status-online-dot" aria-label="在线" title="在线" /> <span className="status-online-dot" aria-label="已登录" title="已登录" />
</span> </span>
</button> </button>
<Button className="status-upgrade" size="mini" type="primary" onClick={() => setUpgradeOpen(true)}></Button>
</div> </div>
<Space className="status-system" size={12}>
<Space className="status-system" size={18}> <span></span>
<span><IconInteractionFallback /> 3 </span>
<span><IconRobot /> AI </span>
<span><IconStorage /> 152GB </span>
<span> <b></b></span>
<IconApps />
</Space> </Space>
</Footer> </Footer>
@@ -49,66 +58,36 @@ export function ProjectStatusbar() {
title="个人资料" title="个人资料"
visible={profileOpen} visible={profileOpen}
onCancel={() => setProfileOpen(false)} onCancel={() => setProfileOpen(false)}
onOk={() => setProfileOpen(false)} onOk={() => void saveProfile()}
confirmLoading={saving}
okText="保存" okText="保存"
cancelText="取消" cancelText="取消"
footer={(cancel, ok) => (
<Space>
<Button status="danger" onClick={onLogout}>退</Button>
{cancel}
{ok}
</Space>
)}
> >
<Form layout="vertical" className="status-modal-form"> <Form layout="vertical" className="status-modal-form">
<Form.Item label="头像"> <Form.Item label="头像">
<Space> <Avatar size={42} style={{ backgroundColor: '#165DFF' }}>{user.displayName.slice(0, 1) || <IconUser />}</Avatar>
<Avatar size={42} style={{ backgroundColor: '#165DFF' }}></Avatar>
<Button icon={<IconUser />}></Button>
</Space>
</Form.Item> </Form.Item>
<Form.Item label="名称"> <Form.Item label="名称" required>
<Input defaultValue="张明" placeholder="请输入名称" /> <Input value={displayName} onChange={setDisplayName} placeholder="请输入名称" />
</Form.Item> </Form.Item>
<Form.Item label="邮箱"> <Form.Item label="邮箱">
<Input defaultValue="zhangming@senlin.ai" placeholder="请输入邮箱" /> <Input value={user.email} disabled />
</Form.Item> </Form.Item>
<Form.Item label="当前密码"> <Form.Item label="当前密码">
<Input.Password placeholder="用于确认身份" /> <Input.Password value={currentPassword} onChange={setCurrentPassword} placeholder="修改密码时必填" />
</Form.Item> </Form.Item>
<Form.Item label="新密码"> <Form.Item label="新密码">
<Input.Password placeholder="不修改密码可留空" /> <Input.Password value={newPassword} onChange={setNewPassword} placeholder="至少 8 个字符;不修改可留空" />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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, DatasetSyncResultDTO,
} from '../api/dataset' } from '../api/dataset'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types' 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' import type { DocumentOpenIntent } from '../api/documents'
const { Content } = Layout const { Content } = Layout
@@ -69,6 +69,10 @@ export function ProjectPage({
onUpdateDatasetItem, onUpdateDatasetItem,
onSyncDatasetSources, onSyncDatasetSources,
onDepositDatasetItem, onDepositDatasetItem,
currentUser,
onUpdateProfile,
onLogout,
onCreateProjectChannel,
}: { }: {
activeView: WorkbenchView activeView: WorkbenchView
activeWorkspace?: ProjectWorkspace activeWorkspace?: ProjectWorkspace
@@ -113,6 +117,10 @@ export function ProjectPage({
onUpdateDatasetItem: (itemId: string, input: DatasetItemUpdate) => Promise<DatasetItemDTO> onUpdateDatasetItem: (itemId: string, input: DatasetItemUpdate) => Promise<DatasetItemDTO>
onSyncDatasetSources: () => Promise<DatasetSyncResultDTO[]> onSyncDatasetSources: () => Promise<DatasetSyncResultDTO[]>
onDepositDatasetItem: (itemId: string, projectId: string) => Promise<DatasetDepositDTO & { refreshFailed?: boolean }> 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 isProject = activeView === 'project' && Boolean(activeWorkspace)
const projects = workspaces.map((workspace) => workspace.project) const projects = workspaces.map((workspace) => workspace.project)
@@ -196,6 +204,7 @@ export function ProjectPage({
onCreateTask={onCreateTask} onCreateTask={onCreateTask}
onCreateCronPlan={onCreateCronPlan} onCreateCronPlan={onCreateCronPlan}
onCreateProjectTag={onCreateProjectTag} onCreateProjectTag={onCreateProjectTag}
onCreateProjectChannel={onCreateProjectChannel}
onUpdateTask={updateActiveProjectTask} onUpdateTask={updateActiveProjectTask}
onAnalyzeInbox={onAnalyzeInbox} onAnalyzeInbox={onAnalyzeInbox}
onConfirmInbox={onConfirmInbox} onConfirmInbox={onConfirmInbox}
@@ -210,7 +219,7 @@ export function ProjectPage({
</Layout> </Layout>
<ProjectStatusbar /> <ProjectStatusbar user={currentUser} onUpdateProfile={onUpdateProfile} onLogout={onLogout} />
</Layout> </Layout>
) )
} }

View File

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

View File

@@ -1,9 +1,12 @@
package auth package auth
import ( import (
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
) )
type Handler struct { type Handler struct {
@@ -16,6 +19,8 @@ func NewHandler(service *Service) *Handler {
func (h *Handler) Register(router gin.IRouter) { func (h *Handler) Register(router gin.IRouter) {
router.POST("/auth/login", h.login) 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) { func (h *Handler) login(c *gin.Context) {
@@ -32,5 +37,72 @@ func (h *Handler) login(c *gin.Context) {
abortWithError(c, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误") abortWithError(c, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
return 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 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 { func NewService(secret string) *Service {
return &Service{secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour} 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 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 tokenPayload struct {
Type string `json:"type"` Type string `json:"type"`
Subject string `json:"subject"` 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} input := CreateInput{Name: request.Name, ParentIdentity: request.ParentID, Markdown: request.Markdown}
if request.SourceInboxItemID != "" { if request.SourceInboxItemID != "" {
var inbox models.SaInboxItem 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) writeError(c, gorm.ErrRecordNotFound)
return 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,6 +41,12 @@ type CreateCronPlanInput struct {
NextRunAt *time.Time NextRunAt *time.Time
} }
type CreateProjectChannelInput struct {
Title string `json:"title"`
Icon string `json:"icon"`
URL string `json:"url"`
}
// WorkspaceDTO 是项目工作区首屏聚合响应,所有数据库对象均以公开 identity 关联。 // WorkspaceDTO 是项目工作区首屏聚合响应,所有数据库对象均以公开 identity 关联。
type WorkspaceDTO struct { type WorkspaceDTO struct {
Project WorkspaceProjectDTO `json:"project"` Project WorkspaceProjectDTO `json:"project"`

View File

@@ -2,6 +2,7 @@ package projects
import ( import (
"errors" "errors"
"net/url"
"strings" "strings"
"gorm.io/gorm" "gorm.io/gorm"
@@ -64,8 +65,40 @@ var (
ErrProjectNameRequired = errors.New("project name is required") ErrProjectNameRequired = errors.New("project name is required")
ErrProjectIdentifierRequired = errors.New("project identifier is required") ErrProjectIdentifierRequired = errors.New("project identifier is required")
ErrProjectIdentifierConflict = errors.New("project identifier already exists") 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 翻译后的唯一约束错误收敛为稳定业务冲突。 // projectWriteError 将 Postgres/SQLite 经 Gorm 翻译后的唯一约束错误收敛为稳定业务冲突。
func projectWriteError(err error) error { func projectWriteError(err error) error {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {

View File

@@ -110,6 +110,25 @@ func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
require.Equal(t, "RSS 采集和线索沉淀", workspace.Project.Description) 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) { func TestWorkspaceMatchesFrontendContract(t *testing.T) {
database := newTestDB(t) database := newTestDB(t)
service := NewService() service := NewService()