Commit 629459d77f9b1dc3fd758769af57af8dbdfc787c
1 parent
7cac2f68
feat(web): 小镇首页默认公开,登录改为弹窗
- 公开接口 /api/voko/public/world/state、world/feed、leaderboard(全部公会的 Agent 与动态,不含任务正文、钱包、主人) - 首页即小镇地图;接入自己的 Agent、发任务等需要登录时弹出登录窗,登录后回到原目标页 - 顶栏:登录 / 接入我的 Agent / 我的 Agent / 退出;加入申请面板并入「我的 Agent」 - 小镇里角色间距加大,名字标签不再重叠 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
13 changed files
with
177 additions
and
96 deletions
apps/voko-server/src/routes/owner.js
| ... | ... | @@ -107,6 +107,16 @@ export function registerOwnerRoutes(app, services) { |
| 107 | 107 | return { user, agent } |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | + // 公开的小镇视图:不登录就能看到全部公会的 Agent 在做什么(名字、等级、状态、任务标题与赏金;不含任务正文、交付与钱包)。 | |
| 111 | + app.get('/api/voko/public/world/state', async (request) => game.worldState(request.ctx, { guild_id: null })) | |
| 112 | + | |
| 113 | + app.get('/api/voko/public/world/feed', async (request) => { | |
| 114 | + const query = v.parse(FeedQuery, request.query) | |
| 115 | + return { events: (await game.worldFeed(request.ctx, { guild_id: null, since: query.since })).map(feedView) } | |
| 116 | + }) | |
| 117 | + | |
| 118 | + app.get('/api/voko/public/leaderboard', async (request) => ({ scope: 'world', period: 'week', leaderboard: await game.leaderboard(request.ctx, { agent: null, scope: 'world', period: 'week' }) })) | |
| 119 | + | |
| 110 | 120 | app.get('/api/voko/guilds', async (request) => { |
| 111 | 121 | await requireUser(request, services) |
| 112 | 122 | return { guilds: await guilds.list(request.ctx) } | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/f4-growth.mjs
| ... | ... | @@ -98,6 +98,17 @@ await runScenario('f4', async ({ db, check, call, admin, waitForEmail, createOwn |
| 98 | 98 | const otherState = await call('GET', '/v1/world/state', { token: outsider.key }) |
| 99 | 99 | check('F4-05 非成员(未认领)→ 403;别的公会的 Agent 只看到自己的公会', strangerState.status === 403 && strangerFeed.status === 403 && otherState.json.data.guild.id === other && otherState.json.data.agents.every((agent) => agent.id !== taker.id), { strangerState, otherState }) |
| 100 | 100 | |
| 101 | + // 公开的小镇视图:不登录就能看到全部公会的 Agent 与动态,但不含任务正文、钱包等。 | |
| 102 | + const town = await call('GET', '/api/voko/public/world/state') | |
| 103 | + const townFeed = await call('GET', '/api/voko/public/world/feed?since=0') | |
| 104 | + const townBoard = await call('GET', '/api/voko/public/leaderboard') | |
| 105 | + const names = town.json.agents.map((agent) => agent.id) | |
| 106 | + check('公开小镇:不登录可见两个公会的 Agent、动态与全镇排行', town.status === 200 && town.json.guild === null && [taker.id, helper.id, outsider.id].every((id) => names.includes(id)) && typeof town.json.agents[0].guild_name === 'string' && townFeed.json.events.some((event) => event.type === 'task.accepted') && townBoard.json.leaderboard.some((row) => row.agent_id === taker.id), { town: town.status, feed: townFeed.status }) | |
| 107 | + const leaked = JSON.stringify([town.json, townFeed.json, townBoard.json]) | |
| 108 | + check('公开小镇不暴露钱包行、主人、任务正文与 api_key', !leaked.includes('wallet_user_id') && !leaked.includes('owner_user_id') && !leaked.includes('"spec"') && !leaked.includes('vk_live_'), null) | |
| 109 | + const stillPrivate = await call('GET', '/api/voko/agents') | |
| 110 | + check('其余网页接口不登录仍是 401', stillPrivate.status === 401, stillPrivate) | |
| 111 | + | |
| 101 | 112 | // 数据看板。 |
| 102 | 113 | const dashboard = (await call('GET', '/api/voko/admin/dashboard', { token: adminToken })).json.dashboard |
| 103 | 114 | check('数据看板有完成任务数、赚取积分、平均交付周期、一次通过率', dashboard.tasks_settled >= 3 && dashboard.points_earned >= 190 && typeof dashboard.avg_delivery_hours === 'number' && dashboard.first_pass_rate > 0 && dashboard.first_pass_rate <= 1, dashboard) | ... | ... |
apps/voko-web/src/App.vue
| 1 | 1 | <script setup> |
| 2 | -import { ToastHost } from '@drama/ui' | |
| 2 | +import { useI18n } from 'vue-i18n' | |
| 3 | +import { useRouter } from 'vue-router' | |
| 4 | +import { Button, ToastHost } from '@drama/ui' | |
| 5 | + | |
| 6 | +import { session, setToken } from './api.js' | |
| 7 | +import LoginModal from './LoginModal.vue' | |
| 8 | + | |
| 9 | +const { t } = useI18n() | |
| 10 | +const router = useRouter() | |
| 11 | + | |
| 12 | +function logout() { | |
| 13 | + setToken(null) | |
| 14 | + router.push('/') | |
| 15 | +} | |
| 3 | 16 | </script> |
| 4 | 17 | |
| 5 | 18 | <template> |
| 19 | + <header class="voko-top"> | |
| 20 | + <router-link to="/" class="voko-brand">VOKO</router-link> | |
| 21 | + <nav class="voko-top-nav" :aria-label="t('nav.label')"> | |
| 22 | + <template v-if="session.loggedIn"> | |
| 23 | + <router-link to="/agents">{{ t('agents.title') }}</router-link> | |
| 24 | + <Button variant="ghost" @click="logout">{{ t('nav.logout') }}</Button> | |
| 25 | + </template> | |
| 26 | + <template v-else> | |
| 27 | + <Button variant="ghost" @click="router.push({ path: '/login', query: { next: '/agents' } })">{{ t('nav.login') }}</Button> | |
| 28 | + <Button @click="router.push({ path: '/login', query: { next: '/agents' } })">{{ t('nav.connect') }}</Button> | |
| 29 | + </template> | |
| 30 | + </nav> | |
| 31 | + </header> | |
| 6 | 32 | <main class="voko-shell"> |
| 7 | 33 | <router-view /> |
| 8 | 34 | <ToastHost /> |
| 9 | 35 | </main> |
| 36 | + <LoginModal /> | |
| 10 | 37 | </template> |
| 11 | 38 | |
| 12 | 39 | <style> |
| ... | ... | @@ -15,10 +42,30 @@ body { |
| 15 | 42 | background: var(--drama-color-bg); |
| 16 | 43 | color: var(--drama-color-text); |
| 17 | 44 | } |
| 45 | +.voko-top { | |
| 46 | + display: flex; | |
| 47 | + align-items: center; | |
| 48 | + justify-content: space-between; | |
| 49 | + max-width: 1040px; | |
| 50 | + margin: 0 auto; | |
| 51 | + padding: var(--drama-space-md) var(--drama-space-md) 0; | |
| 52 | +} | |
| 53 | +.voko-brand { | |
| 54 | + font-weight: 800; | |
| 55 | + font-size: var(--drama-font-size-lg, 1.25rem); | |
| 56 | + letter-spacing: 0.08em; | |
| 57 | + text-decoration: none; | |
| 58 | + color: inherit; | |
| 59 | +} | |
| 60 | +.voko-top-nav { | |
| 61 | + display: flex; | |
| 62 | + align-items: center; | |
| 63 | + gap: var(--drama-space-md); | |
| 64 | +} | |
| 18 | 65 | .voko-shell { |
| 19 | 66 | max-width: 1040px; |
| 20 | 67 | margin: 0 auto; |
| 21 | - padding: var(--drama-space-xl) var(--drama-space-md); | |
| 68 | + padding: var(--drama-space-md) var(--drama-space-md) var(--drama-space-xl); | |
| 22 | 69 | } |
| 23 | 70 | .voko-field { |
| 24 | 71 | display: flex; | ... | ... |
apps/voko-web/src/pages/LoginPage.vue renamed to apps/voko-web/src/LoginModal.vue
| 1 | 1 | <script setup> |
| 2 | -import { ref } from 'vue' | |
| 2 | +/** | |
| 3 | + * 登录 / 注册弹窗。小镇默认公开可看;只有接入自己的 Agent、发指令时才需要登录。 | |
| 4 | + * 路由到 /login(可带 ?next=)即打开本弹窗,关闭后回到小镇。 | |
| 5 | + */ | |
| 6 | +import { computed, ref } from 'vue' | |
| 3 | 7 | import { useI18n } from 'vue-i18n' |
| 4 | 8 | import { useRoute, useRouter } from 'vue-router' |
| 5 | -import { Button, Card, Input } from '@drama/ui' | |
| 9 | +import { Button, Input, Modal } from '@drama/ui' | |
| 6 | 10 | |
| 7 | -import { api, setToken } from '../api.js' | |
| 11 | +import { api, setToken } from './api.js' | |
| 8 | 12 | |
| 9 | 13 | const { t, locale } = useI18n() |
| 10 | 14 | const router = useRouter() |
| 11 | 15 | const route = useRoute() |
| 16 | +const open = computed(() => route.path === '/login') | |
| 12 | 17 | const mode = ref('login') |
| 13 | 18 | const email = ref('') |
| 14 | 19 | const password = ref('') |
| ... | ... | @@ -16,8 +21,12 @@ const code = ref('') |
| 16 | 21 | const message = ref('') |
| 17 | 22 | |
| 18 | 23 | async function sendCode() { |
| 19 | - await api('POST', '/api/voko/auth/send-code', { email: email.value, locale: locale.value }) | |
| 20 | - message.value = t('login.codeSent') | |
| 24 | + try { | |
| 25 | + await api('POST', '/api/voko/auth/send-code', { email: email.value, locale: locale.value }) | |
| 26 | + message.value = t('login.codeSent') | |
| 27 | + } catch (err) { | |
| 28 | + message.value = err instanceof Error ? err.message : String(err) | |
| 29 | + } | |
| 21 | 30 | } |
| 22 | 31 | |
| 23 | 32 | async function submit() { |
| ... | ... | @@ -26,8 +35,10 @@ async function submit() { |
| 26 | 35 | ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value }) |
| 27 | 36 | : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value }) |
| 28 | 37 | setToken(session.access_token) |
| 29 | - // 只接受站内路径(认领链接登录后回到认领页)。 | |
| 30 | - const next = typeof route.query.next === 'string' && route.query.next.startsWith('/') && !route.query.next.startsWith('//') ? route.query.next : '/' | |
| 38 | + password.value = '' | |
| 39 | + message.value = '' | |
| 40 | + // 只接受站内路径(如认领链接登录后回到认领页)。 | |
| 41 | + const next = typeof route.query.next === 'string' && route.query.next.startsWith('/') && !route.query.next.startsWith('//') ? route.query.next : '/agents' | |
| 31 | 42 | await router.push(next) |
| 32 | 43 | } catch (err) { |
| 33 | 44 | message.value = err instanceof Error ? err.message : String(err) |
| ... | ... | @@ -36,18 +47,18 @@ async function submit() { |
| 36 | 47 | </script> |
| 37 | 48 | |
| 38 | 49 | <template> |
| 39 | - <Card> | |
| 40 | - <h1>{{ mode === 'login' ? t('login.title') : t('login.registerTitle') }}</h1> | |
| 50 | + <Modal :open="open" :title="mode === 'login' ? t('login.title') : t('login.registerTitle')" width="420px" @close="router.push('/')"> | |
| 51 | + <p>{{ t('login.why') }}</p> | |
| 41 | 52 | <label class="voko-field">{{ t('login.email') }}<Input v-model="email" type="email" /></label> |
| 42 | 53 | <label class="voko-field">{{ t('login.password') }}<Input v-model="password" type="password" /></label> |
| 43 | 54 | <template v-if="mode === 'register'"> |
| 44 | 55 | <label class="voko-field">{{ t('login.code') }}<Input v-model="code" /></label> |
| 45 | 56 | <Button variant="secondary" @click="sendCode">{{ t('login.sendCode') }}</Button> |
| 46 | 57 | </template> |
| 47 | - <p v-if="message">{{ message }}</p> | |
| 48 | - <Button @click="submit">{{ mode === 'login' ? t('login.submit') : t('login.register') }}</Button> | |
| 49 | - <Button variant="ghost" @click="mode = mode === 'login' ? 'register' : 'login'"> | |
| 50 | - {{ mode === 'login' ? t('login.toRegister') : t('login.toLogin') }} | |
| 51 | - </Button> | |
| 52 | - </Card> | |
| 58 | + <p v-if="message" role="status">{{ message }}</p> | |
| 59 | + <template #footer> | |
| 60 | + <Button variant="ghost" @click="mode = mode === 'login' ? 'register' : 'login'">{{ mode === 'login' ? t('login.toRegister') : t('login.toLogin') }}</Button> | |
| 61 | + <Button @click="submit">{{ mode === 'login' ? t('login.submit') : t('login.register') }}</Button> | |
| 62 | + </template> | |
| 63 | + </Modal> | |
| 53 | 64 | </template> | ... | ... |
apps/voko-web/src/ai-town/CityCanvas.vue
| ... | ... | @@ -43,9 +43,9 @@ let folk = null |
| 43 | 43 | const actors = new Map() |
| 44 | 44 | let resizeObserver = null |
| 45 | 45 | |
| 46 | -/** 第 i 个站位相对区域落脚点的偏移:4 个一排。 */ | |
| 46 | +/** 第 i 个站位相对区域落脚点的偏移:3 个一排,间距留够名字标签。 */ | |
| 47 | 47 | function slot(index) { |
| 48 | - return { x: ((index % 4) - 1.5) * 44, y: 34 + Math.floor(index / 4) * 56 } | |
| 48 | + return { x: ((index % 3) - 1) * 120, y: 34 + Math.floor(index / 3) * 64 } | |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | 51 | /** 同一个 Agent 始终用同一套小人。 */ |
| ... | ... | @@ -89,7 +89,7 @@ function buildSigns() { |
| 89 | 89 | const label = new Text({ text: props.zoneLabels[zone], style: { fontSize: 22, fontWeight: '700', fill: 0xffffff, stroke: { color: 0x1f2937, width: 5 } } }) |
| 90 | 90 | label.anchor.set(0.5, 1) |
| 91 | 91 | label.position.set(point.x, point.y - 48) |
| 92 | - const plate = new Graphics().roundRect(point.x - 100, point.y - 40, 200, 150, 14).fill({ color: 0xffffff, alpha: 0.18 }).stroke({ color: 0xffffff, alpha: 0.5, width: 2 }) | |
| 92 | + const plate = new Graphics().roundRect(point.x - 190, point.y - 40, 380, 170, 14).fill({ color: 0xffffff, alpha: 0.18 }).stroke({ color: 0xffffff, alpha: 0.5, width: 2 }) | |
| 93 | 93 | signs.addChild(plate, label) |
| 94 | 94 | } |
| 95 | 95 | return signs |
| ... | ... | @@ -130,7 +130,7 @@ function sync() { |
| 130 | 130 | actors.set(agent.id, actor) |
| 131 | 131 | } |
| 132 | 132 | actor.target = target |
| 133 | - actor.name.text = `${agent.name.length > 14 ? `${agent.name.slice(0, 13)}…` : agent.name} · Lv${agent.level}` | |
| 133 | + actor.name.text = `${agent.name.length > 12 ? `${agent.name.slice(0, 11)}…` : agent.name} · Lv${agent.level}` | |
| 134 | 134 | actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect] |
| 135 | 135 | } |
| 136 | 136 | for (const [id, actor] of actors) { | ... | ... |
apps/voko-web/src/api.js
| ... | ... | @@ -2,8 +2,13 @@ |
| 2 | 2 | * voko-server 网页接口的唯一调用点。access token 存在 sessionStorage(Alpha 内部使用)。 |
| 3 | 3 | */ |
| 4 | 4 | |
| 5 | +import { reactive } from 'vue' | |
| 6 | + | |
| 5 | 7 | const TOKEN_KEY = 'voko.access_token' |
| 6 | 8 | |
| 9 | +/** 登录状态(给顶栏用)。 */ | |
| 10 | +export const session = reactive({ loggedIn: sessionStorage.getItem(TOKEN_KEY) !== null }) | |
| 11 | + | |
| 7 | 12 | export function getToken() { |
| 8 | 13 | return sessionStorage.getItem(TOKEN_KEY) |
| 9 | 14 | } |
| ... | ... | @@ -12,6 +17,7 @@ export function getToken() { |
| 12 | 17 | export function setToken(token) { |
| 13 | 18 | if (token === null) sessionStorage.removeItem(TOKEN_KEY) |
| 14 | 19 | else sessionStorage.setItem(TOKEN_KEY, token) |
| 20 | + session.loggedIn = token !== null | |
| 15 | 21 | } |
| 16 | 22 | |
| 17 | 23 | /** | ... | ... |
apps/voko-web/src/i18n/messages.js
| ... | ... | @@ -15,7 +15,9 @@ export const messages = { |
| 15 | 15 | register: 'Create account', |
| 16 | 16 | toRegister: 'No account? Register', |
| 17 | 17 | toLogin: 'Have an account? Sign in', |
| 18 | + why: 'Watching the town needs no account. Sign in to connect your own agent, publish tasks or answer its questions.', | |
| 18 | 19 | }, |
| 20 | + nav: { label: 'Account', login: 'Sign in', logout: 'Sign out', connect: 'Connect my agent' }, | |
| 19 | 21 | home: { |
| 20 | 22 | title: 'Your VOKO access', |
| 21 | 23 | notApplied: 'Apply to join. An operator reviews each request.', |
| ... | ... | @@ -110,6 +112,9 @@ export const messages = { |
| 110 | 112 | }, |
| 111 | 113 | world: { |
| 112 | 114 | title: 'Guild city', |
| 115 | + town: 'VOKO Town', | |
| 116 | + townIntro: 'Every agent in every guild, live: who is picking up work, who is at their desk, who is asking their human for help, who just got paid.', | |
| 117 | + townLeaderboard: 'This week across the town', | |
| 113 | 118 | guildLevel: 'Level {level}', |
| 114 | 119 | goal: 'Weekly goal: {progress} / {goal} points settled', |
| 115 | 120 | hint: 'Drag to move the map, scroll to zoom. Placeholder art from AI Town for local testing.', |
| ... | ... | @@ -192,7 +197,9 @@ export const messages = { |
| 192 | 197 | register: '注册', |
| 193 | 198 | toRegister: '没有账号?去注册', |
| 194 | 199 | toLogin: '已有账号?去登录', |
| 200 | + why: '看小镇不需要账号。接入自己的 Agent、发任务、回复它的求助时才需要登录。', | |
| 195 | 201 | }, |
| 202 | + nav: { label: '账号', login: '登录', logout: '退出', connect: '接入我的 Agent' }, | |
| 196 | 203 | home: { |
| 197 | 204 | title: '我的 VOKO 准入', |
| 198 | 205 | notApplied: '提交加入申请,运营会人工审核。', |
| ... | ... | @@ -287,6 +294,9 @@ export const messages = { |
| 287 | 294 | }, |
| 288 | 295 | world: { |
| 289 | 296 | title: '公会城', |
| 297 | + town: 'VOKO 小镇', | |
| 298 | + townIntro: '所有公会的 Agent 都在这里,实时的:谁在接任务、谁在工位上干活、谁在找主人求助、谁刚拿到赏金。', | |
| 299 | + townLeaderboard: '本周全镇排行', | |
| 290 | 300 | guildLevel: '{level} 级', |
| 291 | 301 | goal: '本周目标:已结算 {progress} / {goal} 积分', |
| 292 | 302 | hint: '拖动可移动地图,滚轮缩放。画面素材来自 AI Town,仅本地测试用。', | ... | ... |
apps/voko-web/src/main.js
| ... | ... | @@ -14,9 +14,7 @@ import ApprovalsPage from './pages/ApprovalsPage.vue' |
| 14 | 14 | import BoardPage from './pages/BoardPage.vue' |
| 15 | 15 | import ClaimPage from './pages/ClaimPage.vue' |
| 16 | 16 | import HelpPage from './pages/HelpPage.vue' |
| 17 | -import HomePage from './pages/HomePage.vue' | |
| 18 | 17 | import LedgerPage from './pages/LedgerPage.vue' |
| 19 | -import LoginPage from './pages/LoginPage.vue' | |
| 20 | 18 | import SettingsPage from './pages/SettingsPage.vue' |
| 21 | 19 | import TaskPage from './pages/TaskPage.vue' |
| 22 | 20 | import WorldPage from './pages/WorldPage.vue' |
| ... | ... | @@ -24,8 +22,9 @@ import WorldPage from './pages/WorldPage.vue' |
| 24 | 22 | const router = createRouter({ |
| 25 | 23 | history: createWebHistory(), |
| 26 | 24 | routes: [ |
| 27 | - { path: '/', component: HomePage }, | |
| 28 | - { path: '/login', component: LoginPage }, | |
| 25 | + // 小镇默认公开可看;/login 仍显示小镇,由 App 里的登录弹窗盖在上面。 | |
| 26 | + { path: '/', component: WorldPage }, | |
| 27 | + { path: '/login', component: WorldPage }, | |
| 29 | 28 | { path: '/admin', component: AdminPage }, |
| 30 | 29 | { path: '/admin/guilds', component: AdminGuildsPage }, |
| 31 | 30 | { path: '/admin/disputes', component: AdminDisputesPage }, | ... | ... |
apps/voko-web/src/pages/AgentsPage.vue
| ... | ... | @@ -9,18 +9,26 @@ import { api, getToken } from '../api.js' |
| 9 | 9 | const { t } = useI18n() |
| 10 | 10 | const router = useRouter() |
| 11 | 11 | const agents = ref(null) |
| 12 | +const me = ref(null) | |
| 13 | +const note = ref('') | |
| 12 | 14 | /** 每个 Agent 的页面状态:刚重置出的 key、上限输入框、提示。 */ |
| 13 | 15 | const local = reactive({}) |
| 14 | 16 | |
| 15 | 17 | const BADGE = { pending_claim: 'neutral', active: 'success', suspended: 'warning' } |
| 16 | 18 | |
| 17 | 19 | async function load() { |
| 20 | + me.value = await api('GET', '/api/voko/me') | |
| 18 | 21 | agents.value = (await api('GET', '/api/voko/agents')).agents |
| 19 | 22 | for (const agent of agents.value) { |
| 20 | 23 | if (local[agent.id] === undefined) local[agent.id] = { key: '', limit: agent.daily_spend_limit === null ? '' : String(agent.daily_spend_limit), message: '' } |
| 21 | 24 | } |
| 22 | 25 | } |
| 23 | 26 | |
| 27 | +async function apply() { | |
| 28 | + await api('POST', '/api/voko/access-requests', { note: note.value }) | |
| 29 | + await load() | |
| 30 | +} | |
| 31 | + | |
| 24 | 32 | /** |
| 25 | 33 | * @param {string} id |
| 26 | 34 | * @param {() => Promise<void>} action |
| ... | ... | @@ -67,8 +75,18 @@ onMounted(async () => { |
| 67 | 75 | <template> |
| 68 | 76 | <h1>{{ t('agents.title') }}</h1> |
| 69 | 77 | <p class="voko-nav"><router-link to="/help">{{ t('help.title') }}</router-link> <router-link to="/approvals">{{ t('agents.approvals') }}</router-link> <router-link to="/settings">{{ t('settings.title') }}</router-link></p> |
| 78 | + <Card v-if="me && (me.access_request === null || me.access_request.status !== 'approved')" class="voko-agent"> | |
| 79 | + <p>{{ me.user.email }}</p> | |
| 80 | + <template v-if="me.access_request === null"> | |
| 81 | + <p>{{ t('home.notApplied') }}</p> | |
| 82 | + <label class="voko-field">{{ t('home.note') }}<Input v-model="note" /></label> | |
| 83 | + <Button @click="apply">{{ t('home.apply') }}</Button> | |
| 84 | + </template> | |
| 85 | + <p v-else>{{ t(`home.status.${me.access_request.status}`) }}</p> | |
| 86 | + </Card> | |
| 87 | + <p v-if="me && me.user.role === 'admin'"><router-link to="/admin">{{ t('home.admin') }}</router-link></p> | |
| 70 | 88 | <template v-if="agents"> |
| 71 | - <p v-if="agents.length === 0">{{ t('agents.empty') }}</p> | |
| 89 | + <p v-if="agents.length === 0 && me.access_request !== null && me.access_request.status === 'approved'">{{ t('agents.empty') }}</p> | |
| 72 | 90 | <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> |
| 73 | 91 | <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2> |
| 74 | 92 | <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p> | ... | ... |
apps/voko-web/src/pages/HomePage.vue deleted
| 1 | -<script setup> | |
| 2 | -import { onMounted, ref } from 'vue' | |
| 3 | -import { useI18n } from 'vue-i18n' | |
| 4 | -import { useRouter } from 'vue-router' | |
| 5 | -import { Button, Card, Input } from '@drama/ui' | |
| 6 | - | |
| 7 | -import { api, getToken } from '../api.js' | |
| 8 | - | |
| 9 | -const { t } = useI18n() | |
| 10 | -const router = useRouter() | |
| 11 | -const me = ref(null) | |
| 12 | -const note = ref('') | |
| 13 | - | |
| 14 | -async function load() { | |
| 15 | - me.value = await api('GET', '/api/voko/me') | |
| 16 | -} | |
| 17 | - | |
| 18 | -async function apply() { | |
| 19 | - await api('POST', '/api/voko/access-requests', { note: note.value }) | |
| 20 | - await load() | |
| 21 | -} | |
| 22 | - | |
| 23 | -onMounted(async () => { | |
| 24 | - if (getToken() === null) { | |
| 25 | - await router.push('/login') | |
| 26 | - return | |
| 27 | - } | |
| 28 | - await load() | |
| 29 | -}) | |
| 30 | -</script> | |
| 31 | - | |
| 32 | -<template> | |
| 33 | - <Card v-if="me"> | |
| 34 | - <h1>{{ t('home.title') }}</h1> | |
| 35 | - <p>{{ me.user.email }}</p> | |
| 36 | - <template v-if="me.access_request === null"> | |
| 37 | - <p>{{ t('home.notApplied') }}</p> | |
| 38 | - <label class="voko-field">{{ t('home.note') }}<Input v-model="note" /></label> | |
| 39 | - <Button @click="apply">{{ t('home.apply') }}</Button> | |
| 40 | - </template> | |
| 41 | - <p v-else>{{ t(`home.status.${me.access_request.status}`) }}</p> | |
| 42 | - <p v-if="me.access_request !== null && me.access_request.status === 'approved'"><router-link to="/agents">{{ t('home.agents') }}</router-link></p> | |
| 43 | - <router-link v-if="me.user.role === 'admin'" to="/admin">{{ t('home.admin') }}</router-link> | |
| 44 | - </Card> | |
| 45 | -</template> |
apps/voko-web/src/pages/WorldPage.vue
| ... | ... | @@ -13,7 +13,9 @@ import { api, getToken } from '../api.js' |
| 13 | 13 | const { t, te } = useI18n() |
| 14 | 14 | const route = useRoute() |
| 15 | 15 | const router = useRouter() |
| 16 | -const base = `/api/voko/agents/${route.params.id}` | |
| 16 | +// 带 Agent id 时看它所在的公会(需要登录);否则是公开的小镇:全部公会的 Agent。 | |
| 17 | +const mine = typeof route.params.id === 'string' | |
| 18 | +const base = mine ? `/api/voko/agents/${route.params.id}` : '/api/voko/public' | |
| 17 | 19 | const state = ref(null) |
| 18 | 20 | const board = ref([]) |
| 19 | 21 | const feed = ref([]) |
| ... | ... | @@ -42,7 +44,7 @@ const placed = computed(() => state.value.agents.map((agent) => { |
| 42 | 44 | return { ...agent, zone: act && act.zone ? act.zone : ZONE_OF_STATE[agent.state], effect: act ? act.effect : null } |
| 43 | 45 | })) |
| 44 | 46 | const zoneLabels = computed(() => Object.fromEntries(ZONES.map((zone) => [zone, t(`world.zones.${zone}`)]))) |
| 45 | -const goalPercent = computed(() => (state.value.guild.weekly_goal === 0 ? 0 : Math.min(100, Math.round((state.value.guild.weekly_progress / state.value.guild.weekly_goal) * 100)))) | |
| 47 | +const goalPercent = computed(() => (state.value.guild === null || state.value.guild.weekly_goal === 0 ? 0 : Math.min(100, Math.round((state.value.guild.weekly_progress / state.value.guild.weekly_goal) * 100)))) | |
| 46 | 48 | |
| 47 | 49 | /** @param {any} event */ |
| 48 | 50 | function play(event) { |
| ... | ... | @@ -55,7 +57,7 @@ function play(event) { |
| 55 | 57 | } |
| 56 | 58 | |
| 57 | 59 | async function refresh() { |
| 58 | - const [nextState, nextFeed, nextBoard] = await Promise.all([api('GET', `${base}/world/state`), api('GET', `${base}/world/feed?since=${cursor}`), api('GET', `${base}/leaderboard?scope=guild&period=week`)]) | |
| 60 | + const [nextState, nextFeed, nextBoard] = await Promise.all([api('GET', `${base}/world/state`), api('GET', `${base}/world/feed?since=${cursor}`), api('GET', mine ? `${base}/leaderboard?scope=guild&period=week` : `${base}/leaderboard`)]) | |
| 59 | 61 | state.value = nextState |
| 60 | 62 | board.value = nextBoard.leaderboard |
| 61 | 63 | const fresh = nextFeed.events |
| ... | ... | @@ -76,7 +78,7 @@ function describe(event) { |
| 76 | 78 | } |
| 77 | 79 | |
| 78 | 80 | onMounted(async () => { |
| 79 | - if (getToken() === null) { | |
| 81 | + if (mine && getToken() === null) { | |
| 80 | 82 | await router.push({ path: '/login', query: { next: route.fullPath } }) |
| 81 | 83 | return |
| 82 | 84 | } |
| ... | ... | @@ -87,10 +89,14 @@ onBeforeUnmount(() => clearInterval(timer)) |
| 87 | 89 | </script> |
| 88 | 90 | |
| 89 | 91 | <template> |
| 90 | - <p><router-link to="/agents">{{ t('board.back') }}</router-link></p> | |
| 92 | + <p v-if="mine"><router-link to="/agents">{{ t('board.back') }}</router-link></p> | |
| 91 | 93 | <template v-if="state"> |
| 92 | - <h1>{{ state.guild.name }} · {{ t('world.guildLevel', { level: state.guild.level }) }}</h1> | |
| 93 | - <p v-if="state.guild.weekly_goal > 0"> | |
| 94 | + <template v-if="state.guild === null"> | |
| 95 | + <h1>{{ t('world.town') }}</h1> | |
| 96 | + <p>{{ t('world.townIntro') }}</p> | |
| 97 | + </template> | |
| 98 | + <h1 v-else>{{ state.guild.name }} · {{ t('world.guildLevel', { level: state.guild.level }) }}</h1> | |
| 99 | + <p v-if="state.guild !== null && state.guild.weekly_goal > 0"> | |
| 94 | 100 | {{ t('world.goal', { progress: state.guild.weekly_progress, goal: state.guild.weekly_goal }) }} |
| 95 | 101 | <progress :value="goalPercent" max="100">{{ goalPercent }}%</progress> |
| 96 | 102 | </p> |
| ... | ... | @@ -99,13 +105,13 @@ onBeforeUnmount(() => clearInterval(timer)) |
| 99 | 105 | <p class="voko-hint">{{ t('world.hint') }}</p> |
| 100 | 106 | <ul class="voko-roster"> |
| 101 | 107 | <li v-for="agent in placed" :key="agent.id"> |
| 102 | - <strong>{{ agent.name }}</strong> · {{ t('agents.level', { level: agent.level }) }} · {{ t(`world.zones.${agent.zone}`) }}<template v-if="agent.titles.length > 0"> · {{ agent.titles.map((title) => t(`world.titles.${title}`)).join(' · ') }}</template> | |
| 108 | + <strong>{{ agent.name }}</strong><template v-if="state.guild === null"> · {{ agent.guild_name }}</template> · {{ t('agents.level', { level: agent.level }) }} · {{ t(`world.zones.${agent.zone}`) }}<template v-if="agent.titles.length > 0"> · {{ agent.titles.map((title) => t(`world.titles.${title}`)).join(' · ') }}</template> | |
| 103 | 109 | </li> |
| 104 | 110 | </ul> |
| 105 | 111 | |
| 106 | 112 | <div class="voko-columns"> |
| 107 | 113 | <Card> |
| 108 | - <h2>{{ t('world.leaderboard') }}</h2> | |
| 114 | + <h2>{{ t(mine ? 'world.leaderboard' : 'world.townLeaderboard') }}</h2> | |
| 109 | 115 | <p v-if="board.length === 0">{{ t('task.none') }}</p> |
| 110 | 116 | <ol><li v-for="row in board" :key="row.agent_id">{{ row.name }} · {{ t('board.reward', { reward: row.points }) }}</li></ol> |
| 111 | 117 | </Card> | ... | ... |
docs/runbooks/verify-alpha.md
| ... | ... | @@ -35,7 +35,11 @@ pnpm --filter @voko/server verify:conservation # 只跑对账 |
| 35 | 35 | |
| 36 | 36 | ## 网页端 |
| 37 | 37 | |
| 38 | -认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board`、任务详情、积分流水 `/agents/:id/ledger`、求助收件箱 `/help`、设置 `/settings`、待审核 `/approvals`、公会城 `/agents/:id/world`、后台 `/admin/guilds`、`/admin/disputes`、`/admin/dashboard` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 | |
| 38 | +认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board`、任务详情、积分流水 `/agents/:id/ledger`、公开小镇首页 `/`(不登录可看,点「接入我的 Agent」弹登录窗)、求助收件箱 `/help`、设置 `/settings`、待审核 `/approvals`、公会城 `/agents/:id/world`、后台 `/admin/guilds`、`/admin/disputes`、`/admin/dashboard` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 | |
| 39 | + | |
| 40 | +## 交付文件 | |
| 41 | + | |
| 42 | +交付文件存在 `apps/voko-server/data/files`(可用 `VOKO_FILES_DIR` 改),不入库。场景脚本结束时会删掉自己上传的文件。这是单机磁盘存储,上 stage / 生产前要换成对象存储。 | |
| 39 | 43 | |
| 40 | 44 | ## 每日对账 |
| 41 | 45 | ... | ... |
packages/voko-exchange/src/game.js
| ... | ... | @@ -84,33 +84,36 @@ export function createGame({ db }) { |
| 84 | 84 | |
| 85 | 85 | /** |
| 86 | 86 | * @param {object} _ctx |
| 87 | - * @param {{ agent: import('./agents.js').Agent, scope: 'guild'|'world', period: 'week'|'all' }} input | |
| 87 | + * @param {{ agent: import('./agents.js').Agent|null, scope: 'guild'|'world', period: 'week'|'all' }} input 公开小镇页没有 agent,只能看世界榜 | |
| 88 | 88 | * @returns {Promise<Array<{ rank: number, agent_id: string, name: string, level: number, points: number }>>} |
| 89 | 89 | */ |
| 90 | 90 | async function leaderboard(_ctx, { agent, scope, period }) { |
| 91 | - const rows = await earnings({ guild_id: scope === 'guild' ? agent.guild_id : null, since: period === 'week' ? weekStart(new Date()) : null, until: null }).orderBy([{ column: 'points', order: 'desc' }, { column: 'a.id', order: 'asc' }]).limit(50) | |
| 91 | + if (scope === 'guild' && agent === null) throw new Error('leaderboard: guild scope requires an agent') | |
| 92 | + const rows = await earnings({ guild_id: agent !== null && scope === 'guild' ? agent.guild_id : null, since: period === 'week' ? weekStart(new Date()) : null, until: null }).orderBy([{ column: 'points', order: 'desc' }, { column: 'a.id', order: 'asc' }]).limit(50) | |
| 92 | 93 | return rows.map((row, index) => ({ rank: index + 1, agent_id: row.agent_id, name: row.name, level: row.level, points: Number(row.points) })) |
| 93 | 94 | } |
| 94 | 95 | |
| 95 | 96 | /** |
| 96 | - * 公会城当前状态:公会等级与本周目标进度,各 Agent 的状态(求助中 / 工作中 / 空闲)与称号。 | |
| 97 | + * 公会城当前状态:各 Agent 的状态(求助中 / 工作中 / 空闲)与称号;指定公会时还有公会等级与本周目标进度。 | |
| 98 | + * `guild_id` 为 null 表示整个小镇(全部公会,公开页面用)。 | |
| 97 | 99 | * @param {object} _ctx |
| 98 | - * @param {{ guild_id: string }} input | |
| 100 | + * @param {{ guild_id: string|null }} input | |
| 99 | 101 | */ |
| 100 | 102 | async function worldState(_ctx, { guild_id }) { |
| 101 | 103 | const now = new Date() |
| 104 | + const inGuild = (/** @type {import('knex').Knex.QueryBuilder} */ query, /** @type {string} */ column) => (guild_id === null ? query : query.where(column, guild_id)) | |
| 102 | 105 | const [guild, agents, working, asking, titles] = await Promise.all([ |
| 103 | - db('vk_guilds').where({ id: guild_id }).first('id', 'name', 'level', 'weekly_goal'), | |
| 104 | - db('vk_agents').where({ guild_id, status: 'active' }).orderBy('created_at', 'asc').select('id', 'name', 'role', 'level', 'xp'), | |
| 105 | - db('vk_tasks').where({ guild_id }).whereIn('status', ['claimed', 'rework']).select('holder_agent_id'), | |
| 106 | - db('vk_help_requests as h').join('vk_tasks as t', 't.id', 'h.task_id').where({ 't.guild_id': guild_id, 'h.status': 'open' }).select('h.agent_id'), | |
| 107 | - db('vk_agent_titles as ti').join('vk_agents as a', 'a.id', 'ti.agent_id').where('a.guild_id', guild_id).select('ti.agent_id', 'ti.title'), | |
| 106 | + guild_id === null ? null : db('vk_guilds').where({ id: guild_id }).first('id', 'name', 'level', 'weekly_goal'), | |
| 107 | + inGuild(db('vk_agents as a').join('vk_guilds as g', 'g.id', 'a.guild_id').where('a.status', 'active'), 'a.guild_id').orderBy('a.created_at', 'asc').select('a.id', 'a.name', 'a.role', 'a.level', 'a.xp', 'g.name as guild_name'), | |
| 108 | + inGuild(db('vk_tasks'), 'guild_id').whereIn('status', ['claimed', 'rework']).select('holder_agent_id'), | |
| 109 | + inGuild(db('vk_help_requests as h').join('vk_tasks as t', 't.id', 'h.task_id').where('h.status', 'open'), 't.guild_id').select('h.agent_id'), | |
| 110 | + inGuild(db('vk_agent_titles as ti').join('vk_agents as a', 'a.id', 'ti.agent_id'), 'a.guild_id').select('ti.agent_id', 'ti.title'), | |
| 108 | 111 | ]) |
| 109 | 112 | if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`) |
| 110 | 113 | const busy = new Set(working.map((row) => row.holder_agent_id)) |
| 111 | 114 | const stuck = new Set(asking.map((row) => row.agent_id)) |
| 112 | 115 | return { |
| 113 | - guild: { id: guild.id, name: guild.name, level: guild.level, weekly_goal: guild.weekly_goal, weekly_progress: await weeklyProgress(db, guild_id, now) }, | |
| 116 | + guild: guild === null ? null : { id: guild.id, name: guild.name, level: guild.level, weekly_goal: guild.weekly_goal, weekly_progress: await weeklyProgress(db, /** @type {string} */ (guild_id), now) }, | |
| 114 | 117 | agents: agents.map((agent) => ({ |
| 115 | 118 | ...agent, |
| 116 | 119 | state: stuck.has(agent.id) ? 'help' : busy.has(agent.id) ? 'working' : 'idle', |
| ... | ... | @@ -120,20 +123,21 @@ export function createGame({ db }) { |
| 120 | 123 | } |
| 121 | 124 | |
| 122 | 125 | /** |
| 123 | - * 公会动态流:同一时刻发给多方的同一事件只出现一次;带任务标题与当事 Agent,供前端翻译成动画。 | |
| 126 | + * 动态流:同一时刻发给多方的同一事件只出现一次;带任务标题与当事 Agent,供前端翻译成动画。 | |
| 127 | + * `guild_id` 为 null 表示整个小镇。 | |
| 124 | 128 | * @param {object} _ctx |
| 125 | - * @param {{ guild_id: string, since: number }} input | |
| 129 | + * @param {{ guild_id: string|null, since: number }} input | |
| 126 | 130 | */ |
| 127 | 131 | async function worldFeed(_ctx, { guild_id, since }) { |
| 128 | - const events = await db('vk_events as e') | |
| 132 | + const query = db('vk_events as e') | |
| 129 | 133 | .leftJoin('vk_tasks as t', 't.id', 'e.task_id') |
| 130 | - .where('e.guild_id', guild_id) | |
| 131 | 134 | .where('e.id', '>', since) |
| 132 | 135 | .groupBy('e.type', 'e.task_id', 'e.created_at', 't.title', 't.reward', 't.rarity', 't.holder_agent_id', 't.publisher_agent_id') |
| 133 | 136 | .orderBy('id', 'asc') |
| 134 | 137 | .limit(100) |
| 135 | 138 | .select(db.raw('MIN(e.id) as id'), 'e.type', 'e.task_id', 'e.created_at', db.raw('MIN(e.target_agent_id) as target_agent_id'), 't.title', 't.reward', 't.rarity', 't.holder_agent_id', 't.publisher_agent_id') |
| 136 | - return events.map((event) => ({ ...event, id: Number(event.id) })) | |
| 139 | + if (guild_id !== null) query.where('e.guild_id', guild_id) | |
| 140 | + return (await query).map((event) => ({ ...event, id: Number(event.id) })) | |
| 137 | 141 | } |
| 138 | 142 | |
| 139 | 143 | /** | ... | ... |