Commit b8189a1988c3cdcb0107374bd244282830d22f93

Authored by yibo
1 parent 629459d7

feat(web): 任务详情页下载交付文件;PM2 增加本地前端进程;本地试运行说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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(&#39;&#39;)
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 () =&gt; {
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 () =&gt; {
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 }
... ...