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 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(&#39;f4&#39;, 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(&#39;&#39;)
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 &#39;./pages/ApprovalsPage.vue&#39;
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 &#39;./pages/WorldPage.vue&#39;
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 &#39;../api.js&#39;
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 () =&gt; {
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 &#39;../api.js&#39;
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(() =&gt; state.value.agents.map((agent) =&gt; {
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(() =&gt; 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(() =&gt; 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 /**
... ...