feat: add profile management page with full CRUD UI

- Add frontend API layer, Pinia store, and 5 components (ProfileCard, ProfilesPanel, ProfileCreateModal, ProfileRenameModal, ProfileImportModal)
- Add ProfilesView page with card grid layout and expandable details
- Modify export endpoint to stream file as browser download instead of returning server path
- Add sidebar nav entry, router route, and i18n translations (en/zh)
- Support create, rename, delete, switch (with gateway restart), export, and import profiles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-16 09:40:25 +08:00
parent 57ef171dda
commit 04b80a616e
13 changed files with 1081 additions and 4 deletions
+22 -4
View File
@@ -1,4 +1,7 @@
import Router from '@koa/router'
import { createReadStream, existsSync, unlinkSync } from 'fs'
import { basename, join } from 'path'
import { tmpdir } from 'os'
import * as hermesCli from '../../services/hermes-cli'
export const profileRoutes = new Router()
@@ -156,14 +159,29 @@ profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
}
})
// POST /api/profiles/:name/export - Export profile to archive
// POST /api/profiles/:name/export - Export profile to archive and download
profileRoutes.post('/api/hermes/profiles/:name/export', async (ctx) => {
const { name } = ctx.params
const { output } = ctx.request.body as { output?: string }
const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`)
try {
const result = await hermesCli.exportProfile(name, output)
ctx.body = { success: true, message: result.trim() }
await hermesCli.exportProfile(name, outputPath)
if (!existsSync(outputPath)) {
ctx.status = 500
ctx.body = { error: 'Export file not found' }
return
}
const filename = basename(outputPath)
ctx.set('Content-Disposition', `attachment; filename="${filename}"`)
ctx.set('Content-Type', 'application/gzip')
ctx.body = createReadStream(outputPath)
// Clean up temp file after response ends
ctx.res.on('finish', () => {
try { unlinkSync(outputPath) } catch { }
})
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }