Commit 629459d77f9b1dc3fd758769af57af8dbdfc787c

Authored by yibo
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>
apps/voko-server/src/routes/owner.js
@@ -107,6 +107,16 @@ export function registerOwnerRoutes(app, services) { @@ -107,6 +107,16 @@ export function registerOwnerRoutes(app, services) {
107 return { user, agent } 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 app.get('/api/voko/guilds', async (request) => { 120 app.get('/api/voko/guilds', async (request) => {
111 await requireUser(request, services) 121 await requireUser(request, services)
112 return { guilds: await guilds.list(request.ctx) } 122 return { guilds: await guilds.list(request.ctx) }
apps/voko-server/tools/scenarios/voko-alpha/f4-growth.mjs
@@ -98,6 +98,17 @@ await runScenario(&#39;f4&#39;, async ({ db, check, call, admin, waitForEmail, createOwn @@ -98,6 +98,17 @@ await runScenario(&#39;f4&#39;, async ({ db, check, call, admin, waitForEmail, createOwn
98 const otherState = await call('GET', '/v1/world/state', { token: outsider.key }) 98 const otherState = await call('GET', '/v1/world/state', { token: outsider.key })
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 }) 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 const dashboard = (await call('GET', '/api/voko/admin/dashboard', { token: adminToken })).json.dashboard 113 const dashboard = (await call('GET', '/api/voko/admin/dashboard', { token: adminToken })).json.dashboard
103 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) 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 <script setup> 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 </script> 16 </script>
4 17
5 <template> 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 <main class="voko-shell"> 32 <main class="voko-shell">
7 <router-view /> 33 <router-view />
8 <ToastHost /> 34 <ToastHost />
9 </main> 35 </main>
  36 + <LoginModal />
10 </template> 37 </template>
11 38
12 <style> 39 <style>
@@ -15,10 +42,30 @@ body { @@ -15,10 +42,30 @@ body {
15 background: var(--drama-color-bg); 42 background: var(--drama-color-bg);
16 color: var(--drama-color-text); 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 .voko-shell { 65 .voko-shell {
19 max-width: 1040px; 66 max-width: 1040px;
20 margin: 0 auto; 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 .voko-field { 70 .voko-field {
24 display: flex; 71 display: flex;
apps/voko-web/src/pages/LoginPage.vue renamed to apps/voko-web/src/LoginModal.vue
1 <script setup> 1 <script setup>
2 -import { ref } from 'vue' 2 +/**
  3 + * 登录 / 注册弹窗。小镇默认公开可看;只有接入自己的 Agent、发指令时才需要登录。
  4 + * 路由到 /login(可带 ?next=)即打开本弹窗,关闭后回到小镇。
  5 + */
  6 +import { computed, ref } from 'vue'
3 import { useI18n } from 'vue-i18n' 7 import { useI18n } from 'vue-i18n'
4 import { useRoute, useRouter } from 'vue-router' 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 const { t, locale } = useI18n() 13 const { t, locale } = useI18n()
10 const router = useRouter() 14 const router = useRouter()
11 const route = useRoute() 15 const route = useRoute()
  16 +const open = computed(() => route.path === '/login')
12 const mode = ref('login') 17 const mode = ref('login')
13 const email = ref('') 18 const email = ref('')
14 const password = ref('') 19 const password = ref('')
@@ -16,8 +21,12 @@ const code = ref(&#39;&#39;) @@ -16,8 +21,12 @@ const code = ref(&#39;&#39;)
16 const message = ref('') 21 const message = ref('')
17 22
18 async function sendCode() { 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 async function submit() { 32 async function submit() {
@@ -26,8 +35,10 @@ async function submit() { @@ -26,8 +35,10 @@ async function submit() {
26 ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value }) 35 ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value })
27 : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value }) 36 : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value })
28 setToken(session.access_token) 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 await router.push(next) 42 await router.push(next)
32 } catch (err) { 43 } catch (err) {
33 message.value = err instanceof Error ? err.message : String(err) 44 message.value = err instanceof Error ? err.message : String(err)
@@ -36,18 +47,18 @@ async function submit() { @@ -36,18 +47,18 @@ async function submit() {
36 </script> 47 </script>
37 48
38 <template> 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 <label class="voko-field">{{ t('login.email') }}<Input v-model="email" type="email" /></label> 52 <label class="voko-field">{{ t('login.email') }}<Input v-model="email" type="email" /></label>
42 <label class="voko-field">{{ t('login.password') }}<Input v-model="password" type="password" /></label> 53 <label class="voko-field">{{ t('login.password') }}<Input v-model="password" type="password" /></label>
43 <template v-if="mode === 'register'"> 54 <template v-if="mode === 'register'">
44 <label class="voko-field">{{ t('login.code') }}<Input v-model="code" /></label> 55 <label class="voko-field">{{ t('login.code') }}<Input v-model="code" /></label>
45 <Button variant="secondary" @click="sendCode">{{ t('login.sendCode') }}</Button> 56 <Button variant="secondary" @click="sendCode">{{ t('login.sendCode') }}</Button>
46 </template> 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 </template> 64 </template>
apps/voko-web/src/ai-town/CityCanvas.vue
@@ -43,9 +43,9 @@ let folk = null @@ -43,9 +43,9 @@ let folk = null
43 const actors = new Map() 43 const actors = new Map()
44 let resizeObserver = null 44 let resizeObserver = null
45 45
46 -/** 第 i 个站位相对区域落脚点的偏移:4 个一排。 */ 46 +/** 第 i 个站位相对区域落脚点的偏移:3 个一排,间距留够名字标签。 */
47 function slot(index) { 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 /** 同一个 Agent 始终用同一套小人。 */ 51 /** 同一个 Agent 始终用同一套小人。 */
@@ -89,7 +89,7 @@ function buildSigns() { @@ -89,7 +89,7 @@ function buildSigns() {
89 const label = new Text({ text: props.zoneLabels[zone], style: { fontSize: 22, fontWeight: '700', fill: 0xffffff, stroke: { color: 0x1f2937, width: 5 } } }) 89 const label = new Text({ text: props.zoneLabels[zone], style: { fontSize: 22, fontWeight: '700', fill: 0xffffff, stroke: { color: 0x1f2937, width: 5 } } })
90 label.anchor.set(0.5, 1) 90 label.anchor.set(0.5, 1)
91 label.position.set(point.x, point.y - 48) 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 signs.addChild(plate, label) 93 signs.addChild(plate, label)
94 } 94 }
95 return signs 95 return signs
@@ -130,7 +130,7 @@ function sync() { @@ -130,7 +130,7 @@ function sync() {
130 actors.set(agent.id, actor) 130 actors.set(agent.id, actor)
131 } 131 }
132 actor.target = target 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 actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect] 134 actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect]
135 } 135 }
136 for (const [id, actor] of actors) { 136 for (const [id, actor] of actors) {
apps/voko-web/src/api.js
@@ -2,8 +2,13 @@ @@ -2,8 +2,13 @@
2 * voko-server 网页接口的唯一调用点。access token 存在 sessionStorage(Alpha 内部使用)。 2 * voko-server 网页接口的唯一调用点。access token 存在 sessionStorage(Alpha 内部使用)。
3 */ 3 */
4 4
  5 +import { reactive } from 'vue'
  6 +
5 const TOKEN_KEY = 'voko.access_token' 7 const TOKEN_KEY = 'voko.access_token'
6 8
  9 +/** 登录状态(给顶栏用)。 */
  10 +export const session = reactive({ loggedIn: sessionStorage.getItem(TOKEN_KEY) !== null })
  11 +
7 export function getToken() { 12 export function getToken() {
8 return sessionStorage.getItem(TOKEN_KEY) 13 return sessionStorage.getItem(TOKEN_KEY)
9 } 14 }
@@ -12,6 +17,7 @@ export function getToken() { @@ -12,6 +17,7 @@ export function getToken() {
12 export function setToken(token) { 17 export function setToken(token) {
13 if (token === null) sessionStorage.removeItem(TOKEN_KEY) 18 if (token === null) sessionStorage.removeItem(TOKEN_KEY)
14 else sessionStorage.setItem(TOKEN_KEY, token) 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,7 +15,9 @@ export const messages = {
15 register: 'Create account', 15 register: 'Create account',
16 toRegister: 'No account? Register', 16 toRegister: 'No account? Register',
17 toLogin: 'Have an account? Sign in', 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 home: { 21 home: {
20 title: 'Your VOKO access', 22 title: 'Your VOKO access',
21 notApplied: 'Apply to join. An operator reviews each request.', 23 notApplied: 'Apply to join. An operator reviews each request.',
@@ -110,6 +112,9 @@ export const messages = { @@ -110,6 +112,9 @@ export const messages = {
110 }, 112 },
111 world: { 113 world: {
112 title: 'Guild city', 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 guildLevel: 'Level {level}', 118 guildLevel: 'Level {level}',
114 goal: 'Weekly goal: {progress} / {goal} points settled', 119 goal: 'Weekly goal: {progress} / {goal} points settled',
115 hint: 'Drag to move the map, scroll to zoom. Placeholder art from AI Town for local testing.', 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,7 +197,9 @@ export const messages = {
192 register: '注册', 197 register: '注册',
193 toRegister: '没有账号?去注册', 198 toRegister: '没有账号?去注册',
194 toLogin: '已有账号?去登录', 199 toLogin: '已有账号?去登录',
  200 + why: '看小镇不需要账号。接入自己的 Agent、发任务、回复它的求助时才需要登录。',
195 }, 201 },
  202 + nav: { label: '账号', login: '登录', logout: '退出', connect: '接入我的 Agent' },
196 home: { 203 home: {
197 title: '我的 VOKO 准入', 204 title: '我的 VOKO 准入',
198 notApplied: '提交加入申请,运营会人工审核。', 205 notApplied: '提交加入申请,运营会人工审核。',
@@ -287,6 +294,9 @@ export const messages = { @@ -287,6 +294,9 @@ export const messages = {
287 }, 294 },
288 world: { 295 world: {
289 title: '公会城', 296 title: '公会城',
  297 + town: 'VOKO 小镇',
  298 + townIntro: '所有公会的 Agent 都在这里,实时的:谁在接任务、谁在工位上干活、谁在找主人求助、谁刚拿到赏金。',
  299 + townLeaderboard: '本周全镇排行',
290 guildLevel: '{level} 级', 300 guildLevel: '{level} 级',
291 goal: '本周目标:已结算 {progress} / {goal} 积分', 301 goal: '本周目标:已结算 {progress} / {goal} 积分',
292 hint: '拖动可移动地图,滚轮缩放。画面素材来自 AI Town,仅本地测试用。', 302 hint: '拖动可移动地图,滚轮缩放。画面素材来自 AI Town,仅本地测试用。',
apps/voko-web/src/main.js
@@ -14,9 +14,7 @@ import ApprovalsPage from &#39;./pages/ApprovalsPage.vue&#39; @@ -14,9 +14,7 @@ import ApprovalsPage from &#39;./pages/ApprovalsPage.vue&#39;
14 import BoardPage from './pages/BoardPage.vue' 14 import BoardPage from './pages/BoardPage.vue'
15 import ClaimPage from './pages/ClaimPage.vue' 15 import ClaimPage from './pages/ClaimPage.vue'
16 import HelpPage from './pages/HelpPage.vue' 16 import HelpPage from './pages/HelpPage.vue'
17 -import HomePage from './pages/HomePage.vue'  
18 import LedgerPage from './pages/LedgerPage.vue' 17 import LedgerPage from './pages/LedgerPage.vue'
19 -import LoginPage from './pages/LoginPage.vue'  
20 import SettingsPage from './pages/SettingsPage.vue' 18 import SettingsPage from './pages/SettingsPage.vue'
21 import TaskPage from './pages/TaskPage.vue' 19 import TaskPage from './pages/TaskPage.vue'
22 import WorldPage from './pages/WorldPage.vue' 20 import WorldPage from './pages/WorldPage.vue'
@@ -24,8 +22,9 @@ import WorldPage from &#39;./pages/WorldPage.vue&#39; @@ -24,8 +22,9 @@ import WorldPage from &#39;./pages/WorldPage.vue&#39;
24 const router = createRouter({ 22 const router = createRouter({
25 history: createWebHistory(), 23 history: createWebHistory(),
26 routes: [ 24 routes: [
27 - { path: '/', component: HomePage },  
28 - { path: '/login', component: LoginPage }, 25 + // 小镇默认公开可看;/login 仍显示小镇,由 App 里的登录弹窗盖在上面。
  26 + { path: '/', component: WorldPage },
  27 + { path: '/login', component: WorldPage },
29 { path: '/admin', component: AdminPage }, 28 { path: '/admin', component: AdminPage },
30 { path: '/admin/guilds', component: AdminGuildsPage }, 29 { path: '/admin/guilds', component: AdminGuildsPage },
31 { path: '/admin/disputes', component: AdminDisputesPage }, 30 { path: '/admin/disputes', component: AdminDisputesPage },
apps/voko-web/src/pages/AgentsPage.vue
@@ -9,18 +9,26 @@ import { api, getToken } from &#39;../api.js&#39; @@ -9,18 +9,26 @@ import { api, getToken } from &#39;../api.js&#39;
9 const { t } = useI18n() 9 const { t } = useI18n()
10 const router = useRouter() 10 const router = useRouter()
11 const agents = ref(null) 11 const agents = ref(null)
  12 +const me = ref(null)
  13 +const note = ref('')
12 /** 每个 Agent 的页面状态:刚重置出的 key、上限输入框、提示。 */ 14 /** 每个 Agent 的页面状态:刚重置出的 key、上限输入框、提示。 */
13 const local = reactive({}) 15 const local = reactive({})
14 16
15 const BADGE = { pending_claim: 'neutral', active: 'success', suspended: 'warning' } 17 const BADGE = { pending_claim: 'neutral', active: 'success', suspended: 'warning' }
16 18
17 async function load() { 19 async function load() {
  20 + me.value = await api('GET', '/api/voko/me')
18 agents.value = (await api('GET', '/api/voko/agents')).agents 21 agents.value = (await api('GET', '/api/voko/agents')).agents
19 for (const agent of agents.value) { 22 for (const agent of agents.value) {
20 if (local[agent.id] === undefined) local[agent.id] = { key: '', limit: agent.daily_spend_limit === null ? '' : String(agent.daily_spend_limit), message: '' } 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 * @param {string} id 33 * @param {string} id
26 * @param {() => Promise<void>} action 34 * @param {() => Promise<void>} action
@@ -67,8 +75,18 @@ onMounted(async () =&gt; { @@ -67,8 +75,18 @@ onMounted(async () =&gt; {
67 <template> 75 <template>
68 <h1>{{ t('agents.title') }}</h1> 76 <h1>{{ t('agents.title') }}</h1>
69 <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> 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 <template v-if="agents"> 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 <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> 90 <Card v-for="agent in agents" :key="agent.id" class="voko-agent">
73 <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2> 91 <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2>
74 <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p> 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 &#39;../api.js&#39; @@ -13,7 +13,9 @@ import { api, getToken } from &#39;../api.js&#39;
13 const { t, te } = useI18n() 13 const { t, te } = useI18n()
14 const route = useRoute() 14 const route = useRoute()
15 const router = useRouter() 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 const state = ref(null) 19 const state = ref(null)
18 const board = ref([]) 20 const board = ref([])
19 const feed = ref([]) 21 const feed = ref([])
@@ -42,7 +44,7 @@ const placed = computed(() =&gt; state.value.agents.map((agent) =&gt; { @@ -42,7 +44,7 @@ const placed = computed(() =&gt; state.value.agents.map((agent) =&gt; {
42 return { ...agent, zone: act && act.zone ? act.zone : ZONE_OF_STATE[agent.state], effect: act ? act.effect : null } 44 return { ...agent, zone: act && act.zone ? act.zone : ZONE_OF_STATE[agent.state], effect: act ? act.effect : null }
43 })) 45 }))
44 const zoneLabels = computed(() => Object.fromEntries(ZONES.map((zone) => [zone, t(`world.zones.${zone}`)]))) 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 /** @param {any} event */ 49 /** @param {any} event */
48 function play(event) { 50 function play(event) {
@@ -55,7 +57,7 @@ function play(event) { @@ -55,7 +57,7 @@ function play(event) {
55 } 57 }
56 58
57 async function refresh() { 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 state.value = nextState 61 state.value = nextState
60 board.value = nextBoard.leaderboard 62 board.value = nextBoard.leaderboard
61 const fresh = nextFeed.events 63 const fresh = nextFeed.events
@@ -76,7 +78,7 @@ function describe(event) { @@ -76,7 +78,7 @@ function describe(event) {
76 } 78 }
77 79
78 onMounted(async () => { 80 onMounted(async () => {
79 - if (getToken() === null) { 81 + if (mine && getToken() === null) {
80 await router.push({ path: '/login', query: { next: route.fullPath } }) 82 await router.push({ path: '/login', query: { next: route.fullPath } })
81 return 83 return
82 } 84 }
@@ -87,10 +89,14 @@ onBeforeUnmount(() =&gt; clearInterval(timer)) @@ -87,10 +89,14 @@ onBeforeUnmount(() =&gt; clearInterval(timer))
87 </script> 89 </script>
88 90
89 <template> 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 <template v-if="state"> 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 {{ t('world.goal', { progress: state.guild.weekly_progress, goal: state.guild.weekly_goal }) }} 100 {{ t('world.goal', { progress: state.guild.weekly_progress, goal: state.guild.weekly_goal }) }}
95 <progress :value="goalPercent" max="100">{{ goalPercent }}%</progress> 101 <progress :value="goalPercent" max="100">{{ goalPercent }}%</progress>
96 </p> 102 </p>
@@ -99,13 +105,13 @@ onBeforeUnmount(() =&gt; clearInterval(timer)) @@ -99,13 +105,13 @@ onBeforeUnmount(() =&gt; clearInterval(timer))
99 <p class="voko-hint">{{ t('world.hint') }}</p> 105 <p class="voko-hint">{{ t('world.hint') }}</p>
100 <ul class="voko-roster"> 106 <ul class="voko-roster">
101 <li v-for="agent in placed" :key="agent.id"> 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 </li> 109 </li>
104 </ul> 110 </ul>
105 111
106 <div class="voko-columns"> 112 <div class="voko-columns">
107 <Card> 113 <Card>
108 - <h2>{{ t('world.leaderboard') }}</h2> 114 + <h2>{{ t(mine ? 'world.leaderboard' : 'world.townLeaderboard') }}</h2>
109 <p v-if="board.length === 0">{{ t('task.none') }}</p> 115 <p v-if="board.length === 0">{{ t('task.none') }}</p>
110 <ol><li v-for="row in board" :key="row.agent_id">{{ row.name }} · {{ t('board.reward', { reward: row.points }) }}</li></ol> 116 <ol><li v-for="row in board" :key="row.agent_id">{{ row.name }} · {{ t('board.reward', { reward: row.points }) }}</li></ol>
111 </Card> 117 </Card>
docs/runbooks/verify-alpha.md
@@ -35,7 +35,11 @@ pnpm --filter @voko/server verify:conservation # 只跑对账 @@ -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,33 +84,36 @@ export function createGame({ db }) {
84 84
85 /** 85 /**
86 * @param {object} _ctx 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 * @returns {Promise<Array<{ rank: number, agent_id: string, name: string, level: number, points: number }>>} 88 * @returns {Promise<Array<{ rank: number, agent_id: string, name: string, level: number, points: number }>>}
89 */ 89 */
90 async function leaderboard(_ctx, { agent, scope, period }) { 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 return rows.map((row, index) => ({ rank: index + 1, agent_id: row.agent_id, name: row.name, level: row.level, points: Number(row.points) })) 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 * @param {object} _ctx 99 * @param {object} _ctx
98 - * @param {{ guild_id: string }} input 100 + * @param {{ guild_id: string|null }} input
99 */ 101 */
100 async function worldState(_ctx, { guild_id }) { 102 async function worldState(_ctx, { guild_id }) {
101 const now = new Date() 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 const [guild, agents, working, asking, titles] = await Promise.all([ 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 if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`) 112 if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`)
110 const busy = new Set(working.map((row) => row.holder_agent_id)) 113 const busy = new Set(working.map((row) => row.holder_agent_id))
111 const stuck = new Set(asking.map((row) => row.agent_id)) 114 const stuck = new Set(asking.map((row) => row.agent_id))
112 return { 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 agents: agents.map((agent) => ({ 117 agents: agents.map((agent) => ({
115 ...agent, 118 ...agent,
116 state: stuck.has(agent.id) ? 'help' : busy.has(agent.id) ? 'working' : 'idle', 119 state: stuck.has(agent.id) ? 'help' : busy.has(agent.id) ? 'working' : 'idle',
@@ -120,20 +123,21 @@ export function createGame({ db }) { @@ -120,20 +123,21 @@ export function createGame({ db }) {
120 } 123 }
121 124
122 /** 125 /**
123 - * 公会动态流:同一时刻发给多方的同一事件只出现一次;带任务标题与当事 Agent,供前端翻译成动画。 126 + * 动态流:同一时刻发给多方的同一事件只出现一次;带任务标题与当事 Agent,供前端翻译成动画。
  127 + * `guild_id` 为 null 表示整个小镇。
124 * @param {object} _ctx 128 * @param {object} _ctx
125 - * @param {{ guild_id: string, since: number }} input 129 + * @param {{ guild_id: string|null, since: number }} input
126 */ 130 */
127 async function worldFeed(_ctx, { guild_id, since }) { 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 .leftJoin('vk_tasks as t', 't.id', 'e.task_id') 133 .leftJoin('vk_tasks as t', 't.id', 'e.task_id')
130 - .where('e.guild_id', guild_id)  
131 .where('e.id', '>', since) 134 .where('e.id', '>', since)
132 .groupBy('e.type', 'e.task_id', 'e.created_at', 't.title', 't.reward', 't.rarity', 't.holder_agent_id', 't.publisher_agent_id') 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 .orderBy('id', 'asc') 136 .orderBy('id', 'asc')
134 .limit(100) 137 .limit(100)
135 .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') 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 /**