feat: add Object Storage browser UI

Full-screen file browser with breadcrumb navigation, directory listing,
file upload/download/delete. Accessible via Settings → Object Storage.
Also adds settings menu with app title editing and KB/obj mode switching.
This commit is contained in:
Fam Zheng 2026-03-04 11:47:08 +00:00
parent 69ad06ca5b
commit 1a907fe3d3
4 changed files with 530 additions and 22 deletions

View File

@ -5,6 +5,7 @@ import WorkflowView from './WorkflowView.vue'
import ReportView from './ReportView.vue' import ReportView from './ReportView.vue'
import CreateForm from './CreateForm.vue' import CreateForm from './CreateForm.vue'
import KbEditor from './KbEditor.vue' import KbEditor from './KbEditor.vue'
import ObjBrowser from './ObjBrowser.vue'
import { api } from '../api' import { api } from '../api'
import type { Project, KbArticleSummary } from '../types' import type { Project, KbArticleSummary } from '../types'
@ -14,25 +15,37 @@ const reportWorkflowId = ref('')
const error = ref('') const error = ref('')
const creating = ref(false) const creating = ref(false)
const showKb = ref(false) const showKb = ref(false)
const showObj = ref(false)
const kbArticles = ref<KbArticleSummary[]>([]) const kbArticles = ref<KbArticleSummary[]>([])
const selectedArticleId = ref('') const selectedArticleId = ref('')
const appTitle = ref('')
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '')
const isReportPage = computed(() => !!reportWorkflowId.value) const isReportPage = computed(() => !!reportWorkflowId.value)
function parseUrl(): { projectId: string; reportId: string; kb: boolean } { function parseUrl(): { projectId: string; reportId: string; kb: boolean; obj: boolean } {
const reportMatch = location.pathname.match(/^\/report\/([^/]+)/) let path = location.pathname
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '', kb: false } if (basePath && path.startsWith(basePath)) {
if (location.pathname.startsWith('/kb')) return { projectId: '', reportId: '', kb: true } path = path.slice(basePath.length) || '/'
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/) }
return { projectId: projectMatch?.[1] ?? '', reportId: '', kb: false } const reportMatch = path.match(/^\/report\/([^/]+)/)
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '', kb: false, obj: false }
if (path.startsWith('/kb')) return { projectId: '', reportId: '', kb: true, obj: false }
if (path.startsWith('/obj')) return { projectId: '', reportId: '', kb: false, obj: true }
const projectMatch = path.match(/^\/projects\/([^/]+)/)
return { projectId: projectMatch?.[1] ?? '', reportId: '', kb: false, obj: false }
} }
function onPopState() { function onPopState() {
const { projectId, reportId, kb } = parseUrl() const { projectId, reportId, kb, obj } = parseUrl()
if (kb) { if (kb) {
onOpenKb() onOpenKb()
} else if (obj) {
onOpenObj()
} else { } else {
showKb.value = false showKb.value = false
showObj.value = false
selectedArticleId.value = '' selectedArticleId.value = ''
selectedProjectId.value = projectId selectedProjectId.value = projectId
reportWorkflowId.value = reportId reportWorkflowId.value = reportId
@ -41,17 +54,23 @@ function onPopState() {
onMounted(async () => { onMounted(async () => {
try { try {
const settings = await api.getSettings().catch(() => ({} as Record<string, string>))
appTitle.value = settings['app_title'] || ''
if (appTitle.value) document.title = appTitle.value
projects.value = await api.listProjects() projects.value = await api.listProjects()
const { projectId, reportId, kb } = parseUrl() const { projectId, reportId, kb, obj } = parseUrl()
if (kb) { if (kb) {
onOpenKb() onOpenKb()
} else if (obj) {
onOpenObj()
} else if (reportId) { } else if (reportId) {
reportWorkflowId.value = reportId reportWorkflowId.value = reportId
} else if (projectId && projects.value.some(p => p.id === projectId)) { } else if (projectId && projects.value.some(p => p.id === projectId)) {
selectedProjectId.value = projectId selectedProjectId.value = projectId
} else if (projects.value[0]) { } else if (projects.value[0]) {
selectedProjectId.value = projects.value[0].id selectedProjectId.value = projects.value[0].id
history.replaceState(null, '', `/projects/${projects.value[0].id}`) history.replaceState(null, '', `${basePath}/projects/${projects.value[0].id}`)
} }
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
@ -68,13 +87,13 @@ function onSelectProject(id: string) {
reportWorkflowId.value = '' reportWorkflowId.value = ''
creating.value = false creating.value = false
showKb.value = false showKb.value = false
history.pushState(null, '', `/projects/${id}`) history.pushState(null, '', `${basePath}/projects/${id}`)
} }
function onStartCreate() { function onStartCreate() {
creating.value = true creating.value = true
selectedProjectId.value = '' selectedProjectId.value = ''
history.pushState(null, '', '/') history.pushState(null, '', `${basePath}/`)
} }
async function onConfirmCreate(req: string) { async function onConfirmCreate(req: string) {
@ -84,7 +103,7 @@ async function onConfirmCreate(req: string) {
await api.createWorkflow(project.id, req) await api.createWorkflow(project.id, req)
creating.value = false creating.value = false
selectedProjectId.value = project.id selectedProjectId.value = project.id
history.pushState(null, '', `/projects/${project.id}`) history.pushState(null, '', `${basePath}/projects/${project.id}`)
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
} }
@ -102,9 +121,9 @@ async function onDeleteProject(id: string) {
if (selectedProjectId.value === id) { if (selectedProjectId.value === id) {
selectedProjectId.value = projects.value[0]?.id ?? '' selectedProjectId.value = projects.value[0]?.id ?? ''
if (selectedProjectId.value) { if (selectedProjectId.value) {
history.replaceState(null, '', `/projects/${selectedProjectId.value}`) history.replaceState(null, '', `${basePath}/projects/${selectedProjectId.value}`)
} else { } else {
history.replaceState(null, '', '/') history.replaceState(null, '', `${basePath}/`)
} }
} }
} catch (e: any) { } catch (e: any) {
@ -116,8 +135,8 @@ async function onOpenKb() {
showKb.value = true showKb.value = true
selectedProjectId.value = '' selectedProjectId.value = ''
creating.value = false creating.value = false
if (location.pathname !== '/kb') { if (location.pathname !== `${basePath}/kb`) {
history.pushState(null, '', '/kb') history.pushState(null, '', `${basePath}/kb`)
} }
try { try {
kbArticles.value = await api.listArticles() kbArticles.value = await api.listArticles()
@ -134,7 +153,25 @@ function onCloseKb() {
selectedArticleId.value = '' selectedArticleId.value = ''
if (projects.value[0]) { if (projects.value[0]) {
selectedProjectId.value = projects.value[0].id selectedProjectId.value = projects.value[0].id
history.replaceState(null, '', `/projects/${projects.value[0].id}`) history.replaceState(null, '', `${basePath}/projects/${projects.value[0].id}`)
}
}
function onOpenObj() {
showObj.value = true
showKb.value = false
selectedProjectId.value = ''
creating.value = false
if (location.pathname !== `${basePath}/obj`) {
history.pushState(null, '', `${basePath}/obj`)
}
}
function onCloseObj() {
showObj.value = false
if (projects.value[0]) {
selectedProjectId.value = projects.value[0].id
history.replaceState(null, '', `${basePath}/projects/${projects.value[0].id}`)
} }
} }
@ -160,6 +197,16 @@ async function onDeleteArticle(id: string) {
} }
} }
async function onUpdateAppTitle(title: string) {
try {
await api.putSetting('app_title', title)
appTitle.value = title
document.title = title
} catch (e: any) {
error.value = e.message
}
}
function onArticleSaved(id: string, title: string, updatedAt: string) { function onArticleSaved(id: string, title: string, updatedAt: string) {
const a = kbArticles.value.find(a => a.id === id) const a = kbArticles.value.find(a => a.id === id)
if (a) { if (a) {
@ -180,21 +227,30 @@ function onArticleSaved(id: string, title: string, updatedAt: string) {
:projects="projects" :projects="projects"
:selectedId="selectedProjectId" :selectedId="selectedProjectId"
:kbMode="showKb" :kbMode="showKb"
:objMode="showObj"
:kbArticles="kbArticles" :kbArticles="kbArticles"
:selectedArticleId="selectedArticleId" :selectedArticleId="selectedArticleId"
:appTitle="appTitle"
@select="onSelectProject" @select="onSelectProject"
@create="onStartCreate" @create="onStartCreate"
@delete="onDeleteProject" @delete="onDeleteProject"
@openKb="onOpenKb" @openKb="onOpenKb"
@closeKb="onCloseKb" @closeKb="onCloseKb"
@openObj="onOpenObj"
@closeObj="onCloseObj"
@selectArticle="selectedArticleId = $event" @selectArticle="selectedArticleId = $event"
@createArticle="onCreateArticle" @createArticle="onCreateArticle"
@deleteArticle="onDeleteArticle" @deleteArticle="onDeleteArticle"
@updateAppTitle="onUpdateAppTitle"
/> />
<main class="main-content"> <main class="main-content">
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div> <div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
<ObjBrowser
v-if="showObj"
@close="onCloseObj"
/>
<KbEditor <KbEditor
v-if="showKb && selectedArticleId" v-else-if="showKb && selectedArticleId"
:articleId="selectedArticleId" :articleId="selectedArticleId"
:key="selectedArticleId" :key="selectedArticleId"
@saved="onArticleSaved" @saved="onArticleSaved"

View File

@ -2,6 +2,8 @@
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import type { ExecutionLogEntry, Comment, LlmCallLogEntry } from '../types' import type { ExecutionLogEntry, Comment, LlmCallLogEntry } from '../types'
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '')
const props = defineProps<{ const props = defineProps<{
entries: ExecutionLogEntry[] entries: ExecutionLogEntry[]
comments: Comment[] comments: Comment[]
@ -191,7 +193,7 @@ watch(logItems, () => {
<!-- Report link --> <!-- Report link -->
<div v-else-if="item.type === 'report'" class="report-link-bar"> <div v-else-if="item.type === 'report'" class="report-link-bar">
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 </a> <a :href="basePath + '/report/' + workflowId" class="report-link" target="_blank">查看报告 </a>
</div> </div>
<!-- LLM Call card (detailed mode) --> <!-- LLM Call card (detailed mode) -->

View File

@ -0,0 +1,348 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
interface ObjEntry {
name: string
type: 'dir' | 'file'
size: number | null
}
const emit = defineEmits<{ close: [] }>()
const currentPath = ref('')
const entries = ref<ObjEntry[]>([])
const loading = ref(false)
const error = ref('')
const uploading = ref(false)
const fileInput = ref<HTMLInputElement>()
const breadcrumbs = computed(() => {
if (!currentPath.value) return [{ label: 'Root', path: '' }]
const parts = currentPath.value.split('/').filter(Boolean)
const crumbs = [{ label: 'Root', path: '' }]
for (let i = 0; i < parts.length; i++) {
crumbs.push({ label: parts[i]!, path: parts.slice(0, i + 1).join('/') })
}
return crumbs
})
const sortedEntries = computed(() => {
const dirs = entries.value.filter(e => e.type === 'dir').sort((a, b) => a.name.localeCompare(b.name))
const files = entries.value.filter(e => e.type === 'file').sort((a, b) => a.name.localeCompare(b.name))
return [...dirs, ...files]
})
async function fetchDir(path: string) {
loading.value = true
error.value = ''
try {
const url = path ? `/api/obj/${path}` : '/api/obj/'
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
entries.value = data.entries
currentPath.value = path
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function navigateTo(path: string) {
fetchDir(path)
}
function onClickEntry(entry: ObjEntry) {
if (entry.type === 'dir') {
const newPath = currentPath.value ? `${currentPath.value}/${entry.name}` : entry.name
fetchDir(newPath)
}
}
function downloadFile(entry: ObjEntry) {
const filePath = currentPath.value ? `${currentPath.value}/${entry.name}` : entry.name
const a = document.createElement('a')
a.href = `/api/obj/${filePath}`
a.download = entry.name
a.click()
}
async function deleteEntry(e: Event, entry: ObjEntry) {
e.stopPropagation()
const label = entry.type === 'dir' ? '目录' : '文件'
if (!confirm(`确定删除${label} "${entry.name}"`)) return
try {
const filePath = currentPath.value ? `${currentPath.value}/${entry.name}` : entry.name
const res = await fetch(`/api/obj/${filePath}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
entries.value = entries.value.filter(item => item.name !== entry.name)
} catch (e: any) {
error.value = e.message
}
}
function triggerUpload() {
fileInput.value?.click()
}
async function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
uploading.value = true
error.value = ''
try {
for (const file of files) {
const filePath = currentPath.value ? `${currentPath.value}/${file.name}` : file.name
const res = await fetch(`/api/obj/${filePath}`, {
method: 'PUT',
body: file,
})
if (!res.ok) throw new Error(`Upload failed: HTTP ${res.status}`)
}
await fetchDir(currentPath.value)
} catch (e: any) {
error.value = e.message
} finally {
uploading.value = false
input.value = ''
}
}
function formatSize(bytes: number | null): string {
if (bytes == null) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
onMounted(() => fetchDir(''))
</script>
<template>
<div class="obj-browser">
<div class="obj-toolbar">
<div class="breadcrumbs">
<template v-for="(crumb, i) in breadcrumbs" :key="crumb.path">
<span v-if="i > 0" class="crumb-sep">/</span>
<button
class="crumb"
:class="{ active: i === breadcrumbs.length - 1 }"
@click="navigateTo(crumb.path)"
>{{ crumb.label }}</button>
</template>
</div>
<div class="toolbar-actions">
<button class="btn-toolbar" @click="fetchDir(currentPath)" :disabled="loading" title="刷新"></button>
<button class="btn-toolbar btn-upload" @click="triggerUpload" :disabled="uploading" title="上传">
{{ uploading ? '上传中...' : '↑ 上传' }}
</button>
<input ref="fileInput" type="file" multiple style="display:none" @change="onFileSelected" />
</div>
</div>
<div v-if="error" class="obj-error" @click="error = ''">{{ error }}</div>
<div v-if="loading && entries.length === 0" class="obj-loading">加载中...</div>
<div v-else class="obj-list">
<div
v-for="entry in sortedEntries"
:key="entry.name"
class="obj-item"
:class="{ clickable: entry.type === 'dir' }"
@click="onClickEntry(entry)"
>
<span class="obj-icon">{{ entry.type === 'dir' ? '📁' : '📄' }}</span>
<span class="obj-name">{{ entry.name }}</span>
<span class="obj-size">{{ entry.type === 'file' ? formatSize(entry.size) : '' }}</span>
<div class="obj-actions">
<button v-if="entry.type === 'file'" class="btn-action" @click.stop="downloadFile(entry)" title="下载"></button>
<button class="btn-action btn-action-delete" @click="deleteEntry($event, entry)" title="删除">×</button>
</div>
</div>
<div v-if="!loading && sortedEntries.length === 0" class="obj-empty">空目录</div>
</div>
</div>
</template>
<style scoped>
.obj-browser {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.obj-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
gap: 12px;
flex-shrink: 0;
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.crumb {
background: none;
border: none;
color: var(--accent);
font-size: 13px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.crumb:hover {
background: var(--bg-tertiary);
}
.crumb.active {
color: var(--text-primary);
font-weight: 600;
cursor: default;
}
.crumb-sep {
color: var(--text-secondary);
font-size: 12px;
}
.toolbar-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-toolbar {
padding: 6px 12px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn-toolbar:hover {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
.btn-toolbar:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.obj-error {
background: var(--error, #e74c3c);
color: #fff;
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
flex-shrink: 0;
}
.obj-loading,
.obj-empty {
padding: 40px;
text-align: center;
color: var(--text-secondary);
font-size: 14px;
}
.obj-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.obj-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
gap: 8px;
}
.obj-item:hover {
background: var(--bg-tertiary);
}
.obj-item.clickable {
cursor: pointer;
}
.obj-icon {
flex-shrink: 0;
font-size: 16px;
width: 24px;
text-align: center;
}
.obj-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.obj-size {
flex-shrink: 0;
font-size: 12px;
color: var(--text-secondary);
min-width: 60px;
text-align: right;
}
.obj-actions {
display: none;
gap: 4px;
flex-shrink: 0;
}
.obj-item:hover .obj-actions {
display: flex;
}
.btn-action {
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 16px;
line-height: 1;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-action:hover {
background: var(--accent);
color: #fff;
}
.btn-action-delete:hover {
background: var(--error, #e74c3c);
}
</style>

View File

@ -2,12 +2,14 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { Project, KbArticleSummary } from '../types' import type { Project, KbArticleSummary } from '../types'
defineProps<{ const props = defineProps<{
projects: Project[] projects: Project[]
selectedId: string selectedId: string
kbMode: boolean kbMode: boolean
objMode: boolean
kbArticles: KbArticleSummary[] kbArticles: KbArticleSummary[]
selectedArticleId: string selectedArticleId: string
appTitle?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -16,12 +18,30 @@ const emit = defineEmits<{
delete: [id: string] delete: [id: string]
openKb: [] openKb: []
closeKb: [] closeKb: []
openObj: []
closeObj: []
selectArticle: [id: string] selectArticle: [id: string]
createArticle: [] createArticle: []
deleteArticle: [id: string] deleteArticle: [id: string]
updateAppTitle: [title: string]
}>() }>()
const showSettings = ref(false) const showSettings = ref(false)
const editingTitle = ref(false)
const titleInput = ref('')
function onEditTitle() {
titleInput.value = props.appTitle || 'Tori'
editingTitle.value = true
}
function onSaveTitle() {
const val = titleInput.value.trim()
if (val && val !== props.appTitle) {
emit('updateAppTitle', val)
}
editingTitle.value = false
}
function onDelete(e: Event, id: string) { function onDelete(e: Event, id: string) {
e.stopPropagation() e.stopPropagation()
@ -41,12 +61,25 @@ function onOpenKb() {
showSettings.value = false showSettings.value = false
emit('openKb') emit('openKb')
} }
function onOpenObj() {
showSettings.value = false
emit('openObj')
}
</script> </script>
<template> <template>
<aside class="sidebar"> <aside class="sidebar">
<!-- Obj Mode -->
<template v-if="objMode">
<div class="sidebar-header">
<button class="btn-back" @click="emit('closeObj')"> Back</button>
<h1 class="logo">Object Storage</h1>
</div>
</template>
<!-- KB Mode --> <!-- KB Mode -->
<template v-if="kbMode"> <template v-else-if="kbMode">
<div class="sidebar-header"> <div class="sidebar-header">
<button class="btn-back" @click="emit('closeKb')"> Back</button> <button class="btn-back" @click="emit('closeKb')"> Back</button>
<h1 class="logo">Knowledge Base</h1> <h1 class="logo">Knowledge Base</h1>
@ -73,7 +106,7 @@ function onOpenKb() {
<!-- Normal Mode --> <!-- Normal Mode -->
<template v-else> <template v-else>
<div class="sidebar-header"> <div class="sidebar-header">
<h1 class="logo">Tori</h1> <h1 class="logo">{{ props.appTitle || 'Tori' }}</h1>
<button class="btn-new" @click="emit('create')">+ 新项目</button> <button class="btn-new" @click="emit('create')">+ 新项目</button>
</div> </div>
<nav class="project-list"> <nav class="project-list">
@ -95,7 +128,22 @@ function onOpenKb() {
<div class="settings-wrapper"> <div class="settings-wrapper">
<button class="btn-settings" @click="showSettings = !showSettings">Settings</button> <button class="btn-settings" @click="showSettings = !showSettings">Settings</button>
<div v-if="showSettings" class="settings-menu"> <div v-if="showSettings" class="settings-menu">
<div class="settings-item-row" v-if="!editingTitle">
<span class="settings-label">App Title</span>
<button class="settings-value" @click="onEditTitle">{{ props.appTitle || 'Tori' }}</button>
</div>
<div class="settings-item-row" v-else>
<input
class="settings-input"
v-model="titleInput"
@keyup.enter="onSaveTitle"
@keyup.escape="editingTitle = false"
@vue:mounted="($event: any) => $event.el.focus()"
/>
<button class="settings-save" @click="onSaveTitle">OK</button>
</div>
<button class="settings-item" @click="onOpenKb">Knowledge Base</button> <button class="settings-item" @click="onOpenKb">Knowledge Base</button>
<button class="settings-item" @click="onOpenObj">Object Storage</button>
</div> </div>
</div> </div>
</div> </div>
@ -292,4 +340,58 @@ function onOpenKb() {
.settings-item:hover { .settings-item:hover {
background: var(--bg-tertiary); background: var(--bg-tertiary);
} }
.settings-item-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
}
.settings-label {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.settings-value {
flex: 1;
text-align: right;
background: none;
border: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}
.settings-value:hover {
background: var(--bg-tertiary);
}
.settings-input {
flex: 1;
padding: 4px 8px;
font-size: 13px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
outline: none;
}
.settings-input:focus {
border-color: var(--accent);
}
.settings-save {
padding: 4px 8px;
font-size: 12px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style> </style>