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,3 +46,20 @@ export async function api(method, path, body, idempotencyKey) { | ||
| 46 | if (!response.ok) throw new Error(json.error.message) | 46 | if (!response.ok) throw new Error(json.error.message) |
| 47 | return json | 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,6 +108,8 @@ export const messages = { | ||
| 108 | noSubmissions: 'Nothing visible yet. Submissions are shown to the publisher and the submitter; contest entries stay sealed until the deadline.', | 108 | noSubmissions: 'Nothing visible yet. Submissions are shown to the publisher and the submitter; contest entries stay sealed until the deadline.', |
| 109 | comments: 'Comments', | 109 | comments: 'Comments', |
| 110 | round: 'Round {round}', | 110 | round: 'Round {round}', |
| 111 | + download: 'Download', | ||
| 112 | + downloadFile: 'Download {filename}', | ||
| 111 | none: 'None yet.', | 113 | none: 'None yet.', |
| 112 | }, | 114 | }, |
| 113 | world: { | 115 | world: { |
| @@ -290,6 +292,8 @@ export const messages = { | @@ -290,6 +292,8 @@ export const messages = { | ||
| 290 | noSubmissions: '暂无可见的交付。交付只对发布方和提交者本人可见;擂台赛的提交在截止前封存。', | 292 | noSubmissions: '暂无可见的交付。交付只对发布方和提交者本人可见;擂台赛的提交在截止前封存。', |
| 291 | comments: '评论', | 293 | comments: '评论', |
| 292 | round: '第 {round} 轮', | 294 | round: '第 {round} 轮', |
| 295 | + download: '下载', | ||
| 296 | + downloadFile: '下载 {filename}', | ||
| 293 | none: '暂无。', | 297 | none: '暂无。', |
| 294 | }, | 298 | }, |
| 295 | world: { | 299 | world: { |
apps/voko-web/src/pages/TaskPage.vue
| @@ -2,9 +2,9 @@ | @@ -2,9 +2,9 @@ | ||
| 2 | import { onMounted, ref } from 'vue' | 2 | import { onMounted, ref } from 'vue' |
| 3 | import { useI18n } from 'vue-i18n' | 3 | import { useI18n } from 'vue-i18n' |
| 4 | import { useRoute, useRouter } from 'vue-router' | 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 | const { t } = useI18n() | 9 | const { t } = useI18n() |
| 10 | const route = useRoute() | 10 | const route = useRoute() |
| @@ -14,6 +14,19 @@ const error = ref('') | @@ -14,6 +14,19 @@ const error = ref('') | ||
| 14 | 14 | ||
| 15 | const when = (/** @type {string} */ iso) => new Date(iso).toLocaleString() | 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 | onMounted(async () => { | 30 | onMounted(async () => { |
| 18 | if (getToken() === null) { | 31 | if (getToken() === null) { |
| 19 | await router.push({ path: '/login', query: { next: route.fullPath } }) | 32 | await router.push({ path: '/login', query: { next: route.fullPath } }) |
| @@ -49,7 +62,15 @@ onMounted(async () => { | @@ -49,7 +62,15 @@ onMounted(async () => { | ||
| 49 | <div v-for="item in detail.submissions" :key="item.id"> | 62 | <div v-for="item in detail.submissions" :key="item.id"> |
| 50 | <p>{{ t('task.round', { round: item.round }) }} · {{ when(item.created_at) }}</p> | 63 | <p>{{ t('task.round', { round: item.round }) }} · {{ when(item.created_at) }}</p> |
| 51 | <p class="voko-pre">{{ item.summary }}</p> | 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 | </div> | 74 | </div> |
| 54 | </Card> | 75 | </Card> |
| 55 | <Card class="voko-section"> | 76 | <Card class="voko-section"> |
| @@ -67,4 +88,14 @@ onMounted(async () => { | @@ -67,4 +88,14 @@ onMounted(async () => { | ||
| 67 | .voko-pre { | 88 | .voko-pre { |
| 68 | white-space: pre-wrap; | 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 | </style> | 101 | </style> |
docs/local-development.md
| @@ -28,3 +28,29 @@ pnpm --filter @voko/server worker # 需要时 | @@ -28,3 +28,29 @@ pnpm --filter @voko/server worker # 需要时 | ||
| 28 | ## 本地管理员 | 28 | ## 本地管理员 |
| 29 | 29 | ||
| 30 | `pnpm dev:user <邮箱> <密码> [显示名] [admin|operator|user]` 对同邮箱 upsert,并重置密码与角色。只能用于本地开发库,禁止用于共享库。已在网页注册的账号可用 `pnpm --filter @voko/server admin:grant <邮箱>` 提升为管理员。 | 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,5 +32,13 @@ module.exports = { | ||
| 32 | script: './src/voko-worker-main.js', | 32 | script: './src/voko-worker-main.js', |
| 33 | interpreter: 'node', | 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 | } |