Commit b8189a1988c3cdcb0107374bd244282830d22f93
1 parent
629459d7
feat(web): 任务详情页下载交付文件;PM2 增加本地前端进程;本地试运行说明
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
5 changed files
with
89 additions
and
3 deletions
apps/voko-web/src/api.js
| ... | ... | @@ -46,3 +46,20 @@ export async function api(method, path, body, idempotencyKey) { |
| 46 | 46 | if (!response.ok) throw new Error(json.error.message) |
| 47 | 47 | return json |
| 48 | 48 | } |
| 49 | + | |
| 50 | +/** | |
| 51 | + * 带登录态下载一个文件并交给浏览器保存(交付文件需要鉴权,不能直接用链接)。 | |
| 52 | + * @param {string} path | |
| 53 | + * @param {string} filename | |
| 54 | + */ | |
| 55 | +export async function download(path, filename) { | |
| 56 | + const token = getToken() | |
| 57 | + const response = await fetch(path, { headers: token === null ? {} : { authorization: `Bearer ${token}` } }) | |
| 58 | + if (!response.ok) throw new Error((await response.json()).error.message) | |
| 59 | + const url = URL.createObjectURL(await response.blob()) | |
| 60 | + const link = document.createElement('a') | |
| 61 | + link.href = url | |
| 62 | + link.download = filename | |
| 63 | + link.click() | |
| 64 | + URL.revokeObjectURL(url) | |
| 65 | +} | ... | ... |
apps/voko-web/src/i18n/messages.js
| ... | ... | @@ -108,6 +108,8 @@ export const messages = { |
| 108 | 108 | noSubmissions: 'Nothing visible yet. Submissions are shown to the publisher and the submitter; contest entries stay sealed until the deadline.', |
| 109 | 109 | comments: 'Comments', |
| 110 | 110 | round: 'Round {round}', |
| 111 | + download: 'Download', | |
| 112 | + downloadFile: 'Download {filename}', | |
| 111 | 113 | none: 'None yet.', |
| 112 | 114 | }, |
| 113 | 115 | world: { |
| ... | ... | @@ -290,6 +292,8 @@ export const messages = { |
| 290 | 292 | noSubmissions: '暂无可见的交付。交付只对发布方和提交者本人可见;擂台赛的提交在截止前封存。', |
| 291 | 293 | comments: '评论', |
| 292 | 294 | round: '第 {round} 轮', |
| 295 | + download: '下载', | |
| 296 | + downloadFile: '下载 {filename}', | |
| 293 | 297 | none: '暂无。', |
| 294 | 298 | }, |
| 295 | 299 | world: { | ... | ... |
apps/voko-web/src/pages/TaskPage.vue
| ... | ... | @@ -2,9 +2,9 @@ |
| 2 | 2 | import { onMounted, ref } from 'vue' |
| 3 | 3 | import { useI18n } from 'vue-i18n' |
| 4 | 4 | import { useRoute, useRouter } from 'vue-router' |
| 5 | -import { Badge, Card } from '@drama/ui' | |
| 5 | +import { Badge, Button, Card } from '@drama/ui' | |
| 6 | 6 | |
| 7 | -import { api, getToken } from '../api.js' | |
| 7 | +import { api, download, getToken } from '../api.js' | |
| 8 | 8 | |
| 9 | 9 | const { t } = useI18n() |
| 10 | 10 | const route = useRoute() |
| ... | ... | @@ -14,6 +14,19 @@ const error = ref('') |
| 14 | 14 | |
| 15 | 15 | const when = (/** @type {string} */ iso) => new Date(iso).toLocaleString() |
| 16 | 16 | |
| 17 | +/** @param {{ file_id: string, filename: string }} artifact */ | |
| 18 | +async function save(artifact) { | |
| 19 | + error.value = '' | |
| 20 | + try { | |
| 21 | + await download(`/api/voko/agents/${route.params.id}/files/${artifact.file_id}`, artifact.filename) | |
| 22 | + } catch (err) { | |
| 23 | + error.value = err instanceof Error ? err.message : String(err) | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +/** @param {number} bytes */ | |
| 28 | +const size = (bytes) => (bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`) | |
| 29 | + | |
| 17 | 30 | onMounted(async () => { |
| 18 | 31 | if (getToken() === null) { |
| 19 | 32 | await router.push({ path: '/login', query: { next: route.fullPath } }) |
| ... | ... | @@ -49,7 +62,15 @@ onMounted(async () => { |
| 49 | 62 | <div v-for="item in detail.submissions" :key="item.id"> |
| 50 | 63 | <p>{{ t('task.round', { round: item.round }) }} · {{ when(item.created_at) }}</p> |
| 51 | 64 | <p class="voko-pre">{{ item.summary }}</p> |
| 52 | - <ul><li v-for="artifact in item.artifacts" :key="artifact.url"><a :href="artifact.url" rel="noopener noreferrer" target="_blank">{{ artifact.note || artifact.url }}</a></li></ul> | |
| 65 | + <ul class="voko-artifacts"> | |
| 66 | + <li v-for="artifact in item.artifacts" :key="artifact.file_id || artifact.url"> | |
| 67 | + <template v-if="artifact.type === 'file'"> | |
| 68 | + <span>{{ artifact.filename }} · {{ size(artifact.size_bytes) }}<template v-if="artifact.note"> · {{ artifact.note }}</template></span> | |
| 69 | + <Button variant="secondary" size="sm" :aria-label="t('task.downloadFile', { filename: artifact.filename })" @click="save(artifact)">{{ t('task.download') }}</Button> | |
| 70 | + </template> | |
| 71 | + <a v-else :href="artifact.url" rel="noopener noreferrer" target="_blank">{{ artifact.note || artifact.url }}</a> | |
| 72 | + </li> | |
| 73 | + </ul> | |
| 53 | 74 | </div> |
| 54 | 75 | </Card> |
| 55 | 76 | <Card class="voko-section"> |
| ... | ... | @@ -67,4 +88,14 @@ onMounted(async () => { |
| 67 | 88 | .voko-pre { |
| 68 | 89 | white-space: pre-wrap; |
| 69 | 90 | } |
| 91 | +.voko-artifacts { | |
| 92 | + padding-left: var(--drama-space-md); | |
| 93 | +} | |
| 94 | +.voko-artifacts li { | |
| 95 | + display: flex; | |
| 96 | + flex-wrap: wrap; | |
| 97 | + align-items: center; | |
| 98 | + gap: var(--drama-space-sm); | |
| 99 | + margin-bottom: var(--drama-space-sm); | |
| 100 | +} | |
| 70 | 101 | </style> | ... | ... |
docs/local-development.md
| ... | ... | @@ -28,3 +28,29 @@ pnpm --filter @voko/server worker # 需要时 |
| 28 | 28 | ## 本地管理员 |
| 29 | 29 | |
| 30 | 30 | `pnpm dev:user <邮箱> <密码> [显示名] [admin|operator|user]` 对同邮箱 upsert,并重置密码与角色。只能用于本地开发库,禁止用于共享库。已在网页注册的账号可用 `pnpm --filter @voko/server admin:grant <邮箱>` 提升为管理员。 |
| 31 | + | |
| 32 | +## 本地试运行(PM2 常驻) | |
| 33 | + | |
| 34 | +```bash | |
| 35 | +pm2 start ecosystem.config.cjs # voko-server(9201)、voko-worker、voko-web(5201,开发服务器) | |
| 36 | +pm2 restart voko-server voko-worker # 改了服务端代码之后 | |
| 37 | +pm2 logs voko-server # 看日志 | |
| 38 | +pm2 stop voko-server voko-worker voko-web | |
| 39 | +``` | |
| 40 | + | |
| 41 | +| 端 | 地址 | | |
| 42 | +| --- | --- | | |
| 43 | +| 小镇首页(不登录可看) | http://localhost:5201/ | | |
| 44 | +| 员工端「我的 Agent」 | http://localhost:5201/agents | | |
| 45 | +| 运营后台 | http://localhost:5201/admin | | |
| 46 | +| 公开 API | http://localhost:9201/v1 | | |
| 47 | +| 给外部 Agent 的说明文件 | http://localhost:9201/skill.md 、/heartbeat.md 、/skill.json | | |
| 48 | + | |
| 49 | +本地测试账号(只存在于本地库,用 `pnpm dev:user` 创建,可重复执行): | |
| 50 | + | |
| 51 | +```bash | |
| 52 | +pnpm dev:user admin@example.test admin123 # 运营 + 公会负责人 | |
| 53 | +pnpm dev:user owner@example.test owner123 "Test Owner" user # 普通主人 | |
| 54 | +``` | |
| 55 | + | |
| 56 | +新建的普通账号还要提交加入申请并由运营在后台通过,才能认领 Agent。 | ... | ... |
ecosystem.config.cjs
| ... | ... | @@ -32,5 +32,13 @@ module.exports = { |
| 32 | 32 | script: './src/voko-worker-main.js', |
| 33 | 33 | interpreter: 'node', |
| 34 | 34 | }, |
| 35 | + { | |
| 36 | + // 本地试运行用的前端开发服务器(/api 代理到 voko-server)。正式部署是 `vite build` 后的静态托管,不走这个进程。 | |
| 37 | + ...processDefaults, | |
| 38 | + name: 'voko-web', | |
| 39 | + cwd: path.join(repoRoot, 'apps', 'voko-web'), | |
| 40 | + script: './node_modules/vite/bin/vite.js', | |
| 41 | + interpreter: 'node', | |
| 42 | + }, | |
| 35 | 43 | ], |
| 36 | 44 | } | ... | ... |