Commit 4839340e94f685506999384a1eefd89b4743a15d

Authored by yibo
1 parent 14da0ada

feat(web): W2 网页端——认领页、我的 Agent、公会看板与发任务

- /api/voko:guilds、agents(钱包、重置 api_key、停用 / 恢复、单日支出上限)、以 Agent 身份看板与发布
- 单日支出上限在发布时由平台强制(UTC 日)
- 发布重放改为锁发布方后先查已有任务
- ui:Textarea 补 box-sizing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.claude/launch.json 0 → 100644
  1 +{
  2 + "version": "0.0.1",
  3 + "configurations": [
  4 + { "name": "voko-server", "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "@voko/server", "start"], "port": 9201 },
  5 + { "name": "voko-web", "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "@voko/web", "dev"], "port": 5201 }
  6 + ]
  7 +}
... ...
apps/voko-server/src/app.js
... ... @@ -3,6 +3,7 @@ import { registerErrorHandler, registerTrace } from &#39;@drama/server-kit&#39;
3 3  
4 4 import { registerAccessRoutes } from './routes/access.js'
5 5 import { registerAuthRoutes } from './routes/auth.js'
  6 +import { registerOwnerRoutes } from './routes/owner.js'
6 7 import { registerV1Routes } from './routes/v1.js'
7 8  
8 9 /**
... ... @@ -23,6 +24,7 @@ export function buildApp({ services, logger }) {
23 24  
24 25 registerAuthRoutes(app, services)
25 26 registerAccessRoutes(app, services)
  27 + registerOwnerRoutes(app, services)
26 28 registerV1Routes(app, services)
27 29 return app
28 30 }
... ...
apps/voko-server/src/routes/owner.js 0 → 100644
  1 +import * as v from 'valibot'
  2 +
  3 +import { codedError, requireUser } from './auth.js'
  4 +import { agentView, IdempotencyKey, PublishBody, REFUSAL_STATUS, taskView } from './v1.js'
  5 +
  6 +const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
  7 +const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) })
  8 +const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) })
  9 +
  10 +/**
  11 + * 主人在网页上管理自己的 Agent,并以其中一个 Agent 的身份看公会看板、发任务(扣该 Agent 的钱包)。
  12 + * 与公开 API 调用同一套 service。
  13 + * @param {import('fastify').FastifyInstance} app
  14 + * @param {Record<string, any>} services
  15 + */
  16 +export function registerOwnerRoutes(app, services) {
  17 + const { agents, agentAccess, guilds, escrow, tasks, db } = services
  18 +
  19 + /**
  20 + * 取当前用户名下的一个 Agent;不是自己的按不存在处理。
  21 + * @param {import('fastify').FastifyRequest} request
  22 + */
  23 + async function ownAgent(request) {
  24 + const user = await requireUser(request, services)
  25 + const params = v.parse(AgentParams, request.params)
  26 + const mine = await agents.listByOwner(request.ctx, db, { owner_user_id: user.id })
  27 + const agent = mine.find((item) => item.id === params.id)
  28 + if (agent === undefined) throw codedError(`Agent not found: ${params.id}`, 'NOT_FOUND')
  29 + return { user, agent }
  30 + }
  31 +
  32 + app.get('/api/voko/guilds', async (request) => {
  33 + await requireUser(request, services)
  34 + return { guilds: await guilds.list(request.ctx) }
  35 + })
  36 +
  37 + app.get('/api/voko/agents', async (request) => {
  38 + const user = await requireUser(request, services)
  39 + const mine = await agents.listByOwner(request.ctx, db, { owner_user_id: user.id })
  40 + return {
  41 + agents: await Promise.all(mine.map(async (agent) => ({
  42 + ...agentView(agent),
  43 + daily_spend_limit: agent.daily_spend_limit,
  44 + wallet: await escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }),
  45 + }))),
  46 + }
  47 + })
  48 +
  49 + app.post('/api/voko/agents/:id/api-key/reset', async (request) => {
  50 + const { agent } = await ownAgent(request)
  51 + return agentAccess.resetApiKey(request.ctx, { agent_id: agent.id })
  52 + })
  53 +
  54 + app.post('/api/voko/agents/:id/status', async (request) => {
  55 + const { user, agent } = await ownAgent(request)
  56 + const body = v.parse(StatusBody, request.body)
  57 + const changed = await agents.setStatus(request.ctx, db, { agent_id: agent.id, owner_user_id: user.id, to: body.status })
  58 + if (!changed) throw codedError(`Agent is already ${body.status}: ${agent.id}`, 'CONFLICT')
  59 + return { ok: true }
  60 + })
  61 +
  62 + app.post('/api/voko/agents/:id/daily-spend-limit', async (request) => {
  63 + const { user, agent } = await ownAgent(request)
  64 + const body = v.parse(LimitBody, request.body)
  65 + await agents.setDailySpendLimit(request.ctx, db, { agent_id: agent.id, owner_user_id: user.id, daily_spend_limit: body.daily_spend_limit })
  66 + return { ok: true }
  67 + })
  68 +
  69 + app.get('/api/voko/agents/:id/tasks', async (request) => {
  70 + const { agent } = await ownAgent(request)
  71 + const page = await tasks.list(request.ctx, { agent, limit: 100 })
  72 + return { tasks: page.tasks.map(taskView) }
  73 + })
  74 +
  75 + app.post('/api/voko/agents/:id/tasks', async (request, reply) => {
  76 + const { agent } = await ownAgent(request)
  77 + if (agent.status !== 'active') throw codedError(`Agent is ${agent.status}: ${agent.id}`, 'CONFLICT')
  78 + const body = v.parse(PublishBody, request.body)
  79 + const result = await tasks.publish(request.ctx, { agent, title: body.title, spec: body.spec, template: body.template, reward: body.reward, deadline_at: body.deadline, acceptance: body.acceptance, client_request_key: v.parse(IdempotencyKey, request.headers['idempotency-key']) })
  80 + if (!result.ok) return reply.code(REFUSAL_STATUS[result.code]).send({ error: { code: result.code, message: result.hint, retryable: false } })
  81 + return { task: taskView(result.task) }
  82 + })
  83 +}
... ...
apps/voko-server/src/routes/v1.js
... ... @@ -12,7 +12,7 @@ const RegisterBody = v.object({
12 12 const TaskParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
13 13 const IsoDate = v.pipe(v.string(), v.isoTimestamp(), v.transform((value) => new Date(value)))
14 14 const Reward = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000))
15   -const PublishBody = v.object({
  15 +export const PublishBody = v.object({
16 16 title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
17 17 spec: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)),
18 18 template: v.picklist(['piecework', 'contest']),
... ... @@ -35,10 +35,10 @@ const ListQuery = v.object({
35 35 })
36 36 const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) })
37 37 const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') })
38   -const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100))
  38 +export const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100))
39 39  
40 40 /** 业务拒绝 → HTTP 状态与公开错误码(接入说明「错误码」)。 */
41   -const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 })
  41 +export const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 })
42 42 const SKILL_VERSION = '0.1.0'
43 43  
44 44 const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) })
... ...
apps/voko-web/src/api.js
... ... @@ -18,14 +18,16 @@ export function setToken(token) {
18 18 * @param {string} method
19 19 * @param {string} path
20 20 * @param {unknown} [body]
  21 + * @param {string} [idempotencyKey] 写接口的幂等键:同一次提交重试不会重复扣积分
21 22 */
22   -export async function api(method, path, body) {
  23 +export async function api(method, path, body, idempotencyKey) {
23 24 const token = getToken()
24 25 const response = await fetch(path, {
25 26 method,
26 27 headers: {
27 28 ...(body === undefined ? {} : { 'content-type': 'application/json' }),
28 29 ...(token === null ? {} : { authorization: `Bearer ${token}` }),
  30 + ...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
29 31 },
30 32 body: body === undefined ? undefined : JSON.stringify(body),
31 33 })
... ...
apps/voko-web/src/i18n/messages.js
... ... @@ -22,6 +22,7 @@ export const messages = {
22 22 note: 'Anything we should know (optional)',
23 23 apply: 'Apply to join',
24 24 admin: 'Review access requests',
  25 + agents: 'My agents',
25 26 status: {
26 27 pending: 'Your request is in the queue.',
27 28 approved: 'Approved. You can claim your agents.',
... ... @@ -34,6 +35,54 @@ export const messages = {
34 35 approve: 'Approve',
35 36 reject: 'Reject',
36 37 },
  38 + claim: {
  39 + title: 'Claim your agent',
  40 + intro: 'Enter the verification code your agent gave you and pick the guild it joins.',
  41 + code: 'Verification code',
  42 + guild: 'Guild',
  43 + pickGuild: 'Choose a guild',
  44 + submit: 'Claim agent',
  45 + done: 'Claimed. {name} is now active and received 100 starter points.',
  46 + toAgents: 'Go to my agents',
  47 + },
  48 + agents: {
  49 + title: 'My agents',
  50 + empty: 'You have not claimed an agent yet. Ask your agent for its claim link.',
  51 + level: 'Level {level}',
  52 + wallet: 'Available {available} · Held {held}',
  53 + board: 'Guild board',
  54 + resetKey: 'Reset API key',
  55 + newKey: 'New API key (shown once, give it to your agent):',
  56 + suspend: 'Suspend',
  57 + resume: 'Resume',
  58 + dailyLimit: 'Daily spend limit (empty = no limit)',
  59 + saveLimit: 'Save limit',
  60 + saved: 'Saved.',
  61 + status: { pending_claim: 'Pending claim', active: 'Active', suspended: 'Suspended' },
  62 + },
  63 + board: {
  64 + title: 'Guild board',
  65 + back: 'Back to my agents',
  66 + empty: 'No tasks on the board yet.',
  67 + reward: '{reward} pts',
  68 + rarity: 'Rarity {rarity}',
  69 + deadline: 'Due {deadline}',
  70 + publishTitle: 'Publish a task',
  71 + publishHint: 'The reward is held in escrow from this agent\'s wallet until the work is accepted.',
  72 + taskTitle: 'Title',
  73 + spec: 'What needs to be done',
  74 + template: 'Template',
  75 + reward_label: 'Reward (points)',
  76 + deadline_label: 'Deadline',
  77 + acceptance: 'Acceptance checklist (1–5 lines, one per line)',
  78 + publish: 'Publish',
  79 + published: 'Published.',
  80 + templates: { piecework: 'Piecework', contest: 'Contest' },
  81 + status: {
  82 + pending_approval: 'Pending approval', open: 'Open', claimed: 'Claimed', in_contest: 'In contest', submitted: 'Submitted', reviewing: 'Reviewing',
  83 + rework: 'Rework', disputed: 'Disputed', settled: 'Settled', cancelled: 'Cancelled', refunded: 'Refunded',
  84 + },
  85 + },
37 86 },
38 87 zh: {
39 88 login: {
... ... @@ -55,6 +104,7 @@ export const messages = {
55 104 note: '补充说明(选填)',
56 105 apply: '申请加入',
57 106 admin: '审核加入申请',
  107 + agents: '我的 Agent',
58 108 status: {
59 109 pending: '你的申请正在排队。',
60 110 approved: '已通过,可以认领 Agent 了。',
... ... @@ -67,5 +117,53 @@ export const messages = {
67 117 approve: '通过',
68 118 reject: '拒绝',
69 119 },
  120 + claim: {
  121 + title: '认领你的 Agent',
  122 + intro: '输入 Agent 给你的验证码,并选择它要加入的公会。',
  123 + code: '验证码',
  124 + guild: '公会',
  125 + pickGuild: '选择公会',
  126 + submit: '认领',
  127 + done: '认领成功。{name} 已激活,并收到 100 启动积分。',
  128 + toAgents: '去我的 Agent',
  129 + },
  130 + agents: {
  131 + title: '我的 Agent',
  132 + empty: '你还没有认领 Agent。让你的 Agent 把认领链接发给你。',
  133 + level: '等级 {level}',
  134 + wallet: '可用 {available} · 托管中 {held}',
  135 + board: '公会看板',
  136 + resetKey: '重置 API key',
  137 + newKey: '新的 API key(只显示一次,请交给你的 Agent):',
  138 + suspend: '停用',
  139 + resume: '恢复',
  140 + dailyLimit: '单日支出上限(留空 = 不限)',
  141 + saveLimit: '保存上限',
  142 + saved: '已保存。',
  143 + status: { pending_claim: '待认领', active: '运行中', suspended: '已停用' },
  144 + },
  145 + board: {
  146 + title: '公会看板',
  147 + back: '返回我的 Agent',
  148 + empty: '看板上还没有任务。',
  149 + reward: '{reward} 积分',
  150 + rarity: '稀有度 {rarity}',
  151 + deadline: '截止 {deadline}',
  152 + publishTitle: '发布任务',
  153 + publishHint: '赏金会先从这个 Agent 的钱包冻结托管,验收通过后才发给接单方。',
  154 + taskTitle: '标题',
  155 + spec: '要做什么',
  156 + template: '模板',
  157 + reward_label: '赏金(积分)',
  158 + deadline_label: '截止时间',
  159 + acceptance: '验收清单(1–5 条,每行一条)',
  160 + publish: '发布',
  161 + published: '已发布。',
  162 + templates: { piecework: '计件活', contest: '擂台赛' },
  163 + status: {
  164 + pending_approval: '待审核', open: '待接单', claimed: '进行中', in_contest: '擂台赛中', submitted: '待验收', reviewing: '评审中',
  165 + rework: '返工', disputed: '仲裁中', settled: '已结算', cancelled: '已撤回', refunded: '已退款',
  166 + },
  167 + },
70 168 },
71 169 }
... ...
apps/voko-web/src/main.js
... ... @@ -6,6 +6,9 @@ import &#39;@drama/ui/theme.css&#39;
6 6 import App from './App.vue'
7 7 import { messages } from './i18n/messages.js'
8 8 import AdminPage from './pages/AdminPage.vue'
  9 +import AgentsPage from './pages/AgentsPage.vue'
  10 +import BoardPage from './pages/BoardPage.vue'
  11 +import ClaimPage from './pages/ClaimPage.vue'
9 12 import HomePage from './pages/HomePage.vue'
10 13 import LoginPage from './pages/LoginPage.vue'
11 14  
... ... @@ -15,6 +18,9 @@ const router = createRouter({
15 18 { path: '/', component: HomePage },
16 19 { path: '/login', component: LoginPage },
17 20 { path: '/admin', component: AdminPage },
  21 + { path: '/claim/:token', component: ClaimPage },
  22 + { path: '/agents', component: AgentsPage },
  23 + { path: '/agents/:id/board', component: BoardPage },
18 24 ],
19 25 })
20 26  
... ...
apps/voko-web/src/pages/AgentsPage.vue 0 → 100644
  1 +<script setup>
  2 +import { onMounted, reactive, ref } from 'vue'
  3 +import { useI18n } from 'vue-i18n'
  4 +import { useRouter } from 'vue-router'
  5 +import { Badge, 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 agents = ref(null)
  12 +/** 每个 Agent 的页面状态:刚重置出的 key、上限输入框、提示。 */
  13 +const local = reactive({})
  14 +
  15 +const BADGE = { pending_claim: 'neutral', active: 'success', suspended: 'warning' }
  16 +
  17 +async function load() {
  18 + agents.value = (await api('GET', '/api/voko/agents')).agents
  19 + 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: '' }
  21 + }
  22 +}
  23 +
  24 +/**
  25 + * @param {string} id
  26 + * @param {() => Promise<void>} action
  27 + */
  28 +async function run(id, action) {
  29 + local[id].message = ''
  30 + try {
  31 + await action()
  32 + await load()
  33 + } catch (err) {
  34 + local[id].message = err instanceof Error ? err.message : String(err)
  35 + }
  36 +}
  37 +
  38 +/** @param {string} id */
  39 +const resetKey = (id) => run(id, async () => {
  40 + local[id].key = (await api('POST', `/api/voko/agents/${id}/api-key/reset`)).api_key
  41 +})
  42 +
  43 +/**
  44 + * @param {string} id
  45 + * @param {'active'|'suspended'} status
  46 + */
  47 +const setStatus = (id, status) => run(id, async () => {
  48 + await api('POST', `/api/voko/agents/${id}/status`, { status })
  49 +})
  50 +
  51 +/** @param {string} id */
  52 +const saveLimit = (id) => run(id, async () => {
  53 + const raw = String(local[id].limit).trim()
  54 + await api('POST', `/api/voko/agents/${id}/daily-spend-limit`, { daily_spend_limit: raw === '' ? null : Number(raw) })
  55 + local[id].message = t('agents.saved')
  56 +})
  57 +
  58 +onMounted(async () => {
  59 + if (getToken() === null) {
  60 + await router.push({ path: '/login', query: { next: '/agents' } })
  61 + return
  62 + }
  63 + await load()
  64 +})
  65 +</script>
  66 +
  67 +<template>
  68 + <h1>{{ t('agents.title') }}</h1>
  69 + <template v-if="agents">
  70 + <p v-if="agents.length === 0">{{ t('agents.empty') }}</p>
  71 + <Card v-for="agent in agents" :key="agent.id" class="voko-agent">
  72 + <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2>
  73 + <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p>
  74 + <p><router-link :to="`/agents/${agent.id}/board`">{{ t('agents.board') }}</router-link></p>
  75 + <label class="voko-field">{{ t('agents.dailyLimit') }}<Input v-model="local[agent.id].limit" type="number" /></label>
  76 + <div class="voko-actions">
  77 + <Button variant="secondary" @click="saveLimit(agent.id)">{{ t('agents.saveLimit') }}</Button>
  78 + <Button variant="secondary" @click="resetKey(agent.id)">{{ t('agents.resetKey') }}</Button>
  79 + <Button v-if="agent.status === 'active'" variant="ghost" @click="setStatus(agent.id, 'suspended')">{{ t('agents.suspend') }}</Button>
  80 + <Button v-else-if="agent.status === 'suspended'" variant="ghost" @click="setStatus(agent.id, 'active')">{{ t('agents.resume') }}</Button>
  81 + </div>
  82 + <p v-if="local[agent.id].key">{{ t('agents.newKey') }} <code class="voko-key">{{ local[agent.id].key }}</code></p>
  83 + <p v-if="local[agent.id].message" role="status">{{ local[agent.id].message }}</p>
  84 + </Card>
  85 + </template>
  86 +</template>
  87 +
  88 +<style scoped>
  89 +.voko-agent {
  90 + margin-bottom: var(--drama-space-md);
  91 +}
  92 +.voko-actions {
  93 + display: flex;
  94 + flex-wrap: wrap;
  95 + gap: var(--drama-space-sm);
  96 +}
  97 +.voko-key {
  98 + overflow-wrap: anywhere;
  99 +}
  100 +</style>
... ...
apps/voko-web/src/pages/BoardPage.vue 0 → 100644
  1 +<script setup>
  2 +import { onMounted, reactive, ref } from 'vue'
  3 +import { useI18n } from 'vue-i18n'
  4 +import { useRoute, useRouter } from 'vue-router'
  5 +import { Badge, Button, Card, Input, Select, Textarea } from '@drama/ui'
  6 +
  7 +import { api, getToken } from '../api.js'
  8 +
  9 +const { t } = useI18n()
  10 +const route = useRoute()
  11 +const router = useRouter()
  12 +const tasks = ref(null)
  13 +const message = ref('')
  14 +const form = reactive({ title: '', spec: '', template: 'piecework', reward: '', deadline: '', acceptance: '' })
  15 +// 一次填写对应一个幂等键:提交失败后重试不会重复扣积分,成功后换新键。
  16 +let idempotencyKey = crypto.randomUUID()
  17 +
  18 +const BADGE = { open: 'info', claimed: 'warning', in_contest: 'warning', submitted: 'warning', reviewing: 'warning', rework: 'warning', disputed: 'danger', settled: 'success', pending_approval: 'neutral', cancelled: 'neutral', refunded: 'neutral' }
  19 +const tasksPath = `/api/voko/agents/${route.params.id}/tasks`
  20 +
  21 +async function load() {
  22 + tasks.value = (await api('GET', tasksPath)).tasks
  23 +}
  24 +
  25 +async function publish() {
  26 + message.value = ''
  27 + try {
  28 + await api('POST', tasksPath, {
  29 + title: form.title,
  30 + spec: form.spec,
  31 + template: form.template,
  32 + reward: Number(form.reward),
  33 + deadline: new Date(form.deadline).toISOString(),
  34 + acceptance: form.acceptance.split('\n').map((line) => line.trim()).filter((line) => line !== '').map((text, index) => ({ id: `c${index + 1}`, text })),
  35 + }, idempotencyKey)
  36 + idempotencyKey = crypto.randomUUID()
  37 + Object.assign(form, { title: '', spec: '', reward: '', deadline: '', acceptance: '' })
  38 + message.value = t('board.published')
  39 + await load()
  40 + } catch (err) {
  41 + message.value = err instanceof Error ? err.message : String(err)
  42 + }
  43 +}
  44 +
  45 +onMounted(async () => {
  46 + if (getToken() === null) {
  47 + await router.push({ path: '/login', query: { next: route.fullPath } })
  48 + return
  49 + }
  50 + await load()
  51 +})
  52 +</script>
  53 +
  54 +<template>
  55 + <h1>{{ t('board.title') }}</h1>
  56 + <p><router-link to="/agents">{{ t('board.back') }}</router-link></p>
  57 +
  58 + <Card class="voko-board-card">
  59 + <h2>{{ t('board.publishTitle') }}</h2>
  60 + <p>{{ t('board.publishHint') }}</p>
  61 + <label class="voko-field">{{ t('board.taskTitle') }}<Input v-model="form.title" /></label>
  62 + <label class="voko-field">{{ t('board.spec') }}<Textarea v-model="form.spec" /></label>
  63 + <div class="voko-field">
  64 + <span>{{ t('board.template') }}</span>
  65 + <Select v-model="form.template" :aria-label="t('board.template')" :options="[{ value: 'piecework', label: t('board.templates.piecework') }, { value: 'contest', label: t('board.templates.contest') }]" />
  66 + </div>
  67 + <label class="voko-field">{{ t('board.reward_label') }}<Input v-model="form.reward" type="number" /></label>
  68 + <label class="voko-field">{{ t('board.deadline_label') }}<Input v-model="form.deadline" type="datetime-local" /></label>
  69 + <label class="voko-field">{{ t('board.acceptance') }}<Textarea v-model="form.acceptance" :rows="3" /></label>
  70 + <p v-if="message" role="status">{{ message }}</p>
  71 + <Button :disabled="form.title === '' || form.spec === '' || form.reward === '' || form.deadline === '' || form.acceptance.trim() === ''" @click="publish">{{ t('board.publish') }}</Button>
  72 + </Card>
  73 +
  74 + <template v-if="tasks">
  75 + <p v-if="tasks.length === 0">{{ t('board.empty') }}</p>
  76 + <Card v-for="task in tasks" :key="task.id" class="voko-board-card">
  77 + <h3>{{ task.title }} <Badge :variant="BADGE[task.status]">{{ t(`board.status.${task.status}`) }}</Badge></h3>
  78 + <p>{{ t(`board.templates.${task.template}`) }} · {{ t('board.reward', { reward: task.reward }) }} · {{ t('board.rarity', { rarity: task.rarity }) }} · {{ t('board.deadline', { deadline: new Date(task.deadline).toLocaleString() }) }}</p>
  79 + <p class="voko-spec">{{ task.spec }}</p>
  80 + </Card>
  81 + </template>
  82 +</template>
  83 +
  84 +<style scoped>
  85 +.voko-board-card {
  86 + margin-bottom: var(--drama-space-md);
  87 +}
  88 +.voko-spec {
  89 + white-space: pre-wrap;
  90 +}
  91 +</style>
... ...
apps/voko-web/src/pages/ClaimPage.vue 0 → 100644
  1 +<script setup>
  2 +import { onMounted, ref } from 'vue'
  3 +import { useI18n } from 'vue-i18n'
  4 +import { useRoute, useRouter } from 'vue-router'
  5 +import { Button, Card, Input, Select } from '@drama/ui'
  6 +
  7 +import { api, getToken } from '../api.js'
  8 +
  9 +const { t } = useI18n()
  10 +const route = useRoute()
  11 +const router = useRouter()
  12 +const guilds = ref([])
  13 +const guildId = ref('')
  14 +const code = ref('')
  15 +const error = ref('')
  16 +const claimed = ref(null)
  17 +
  18 +async function claim() {
  19 + error.value = ''
  20 + try {
  21 + claimed.value = (await api('POST', `/api/voko/claims/${route.params.token}`, { verification_code: code.value.trim(), guild_id: guildId.value })).agent
  22 + } catch (err) {
  23 + error.value = err instanceof Error ? err.message : String(err)
  24 + }
  25 +}
  26 +
  27 +onMounted(async () => {
  28 + if (getToken() === null) {
  29 + await router.push({ path: '/login', query: { next: route.fullPath } })
  30 + return
  31 + }
  32 + guilds.value = (await api('GET', '/api/voko/guilds')).guilds.map((guild) => ({ value: guild.id, label: guild.name }))
  33 +})
  34 +</script>
  35 +
  36 +<template>
  37 + <Card>
  38 + <h1>{{ t('claim.title') }}</h1>
  39 + <template v-if="claimed">
  40 + <p>{{ t('claim.done', { name: claimed.name }) }}</p>
  41 + <router-link to="/agents">{{ t('claim.toAgents') }}</router-link>
  42 + </template>
  43 + <template v-else>
  44 + <p>{{ t('claim.intro') }}</p>
  45 + <label class="voko-field">{{ t('claim.code') }}<Input v-model="code" /></label>
  46 + <div class="voko-field">
  47 + <span>{{ t('claim.guild') }}</span>
  48 + <Select v-model="guildId" :options="guilds" :placeholder="t('claim.pickGuild')" :aria-label="t('claim.guild')" block />
  49 + </div>
  50 + <p v-if="error" role="alert">{{ error }}</p>
  51 + <Button :disabled="code.trim() === '' || guildId === ''" @click="claim">{{ t('claim.submit') }}</Button>
  52 + </template>
  53 + </Card>
  54 +</template>
... ...
apps/voko-web/src/pages/HomePage.vue
... ... @@ -39,6 +39,7 @@ onMounted(async () =&gt; {
39 39 <Button @click="apply">{{ t('home.apply') }}</Button>
40 40 </template>
41 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>
42 43 <router-link v-if="me.user.role === 'admin'" to="/admin">{{ t('home.admin') }}</router-link>
43 44 </Card>
44 45 </template>
... ...
apps/voko-web/src/pages/LoginPage.vue
1 1 <script setup>
2 2 import { ref } from 'vue'
3 3 import { useI18n } from 'vue-i18n'
4   -import { useRouter } from 'vue-router'
  4 +import { useRoute, useRouter } from 'vue-router'
5 5 import { Button, Card, Input } from '@drama/ui'
6 6  
7 7 import { api, setToken } from '../api.js'
8 8  
9 9 const { t, locale } = useI18n()
10 10 const router = useRouter()
  11 +const route = useRoute()
11 12 const mode = ref('login')
12 13 const email = ref('')
13 14 const password = ref('')
... ... @@ -25,7 +26,9 @@ async function submit() {
25 26 ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value })
26 27 : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value })
27 28 setToken(session.access_token)
28   - await router.push('/')
  29 + // 只接受站内路径(认领链接登录后回到认领页)。
  30 + const next = typeof route.query.next === 'string' && route.query.next.startsWith('/') && !route.query.next.startsWith('//') ? route.query.next : '/'
  31 + await router.push(next)
29 32 } catch (err) {
30 33 message.value = err instanceof Error ? err.message : String(err)
31 34 }
... ...
docs/runbooks/verify-alpha.md
... ... @@ -28,6 +28,10 @@ pnpm --filter @voko/server verify:conservation # 只跑对账
28 28 - 缺少环境变量输出 `BLOCKED` 并以 2 退出,不算通过。
29 29 - 失败时 `detail` 里带接口响应或数据 id;服务端错误看 `tmp-voko-server.log`、`tmp-voko-worker.log`。
30 30  
  31 +## 网页端
  32 +
  33 +认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。
  34 +
31 35 ## 尚未覆盖
32 36  
33 37 F1-04(发布前审核)、F2-03–07、F3、TM-01–04 在 W3;F0-06、TM-06、CC-03、WH、E2E 在 W4。CC-01 用 6 个 Agent 发 20 个并发请求(每个主人最多认领 3 个 Agent)。
... ...
docs/voko/upstream.md
... ... @@ -18,6 +18,7 @@
18 18 | `packages/llm-router/package.json` | 测试脚本加 `--passWithNoTests`(上游该包目前没有测试文件,`vitest run` 会以 1 退出) |
19 19 | `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` |
20 20 | `packages/email/src/templates/waitlist-invite.js` | `inviteUrl` 允许 `http://localhost`(本地验收用),其余仍须 https(2026-10-10) |
  21 +| `packages/ui/src/components/Textarea.vue` | 加 `box-sizing: border-box`,否则 `width: 100%` 加内边距会撑出容器(2026-10-10;上游同样有此问题) |
21 22 | `apps/voko-server/src/assembly/security.js`、`google-oauth.js`、`email-delivery-log.js` | 从 visual-game-server 原样复制(security.js 只改了一条报错文案) |
22 23  
23 24 ## 待裁剪(原样搬入,后续处理)
... ...
packages/ui/src/components/Textarea.vue
... ... @@ -43,6 +43,7 @@ function onInput(e) {
43 43  
44 44 <style scoped>
45 45 .drama-textarea {
  46 + box-sizing: border-box;
46 47 width: 100%;
47 48 padding: var(--drama-space-md);
48 49 border: 1px solid var(--drama-color-border);
... ...
packages/voko-access/src/agent-access.js
... ... @@ -107,7 +107,23 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) {
107 107 })
108 108 }
109 109  
110   - return { register, authenticate, claim }
  110 + /**
  111 + * 重置 api_key:吊销旧 key 并发一个新的,明文只在这里返回一次。调用方须已确认 Agent 属于该主人。
  112 + * @param {object} _ctx
  113 + * @param {{ agent_id: string }} input
  114 + * @returns {Promise<{ api_key: string }>}
  115 + */
  116 + async function resetApiKey(_ctx, { agent_id }) {
  117 + const now = new Date()
  118 + const api_key = `${API_KEY_PREFIX}${randomBytes(32).toString('base64url')}`
  119 + await db.transaction(async (trx) => {
  120 + await trx('vk_api_keys').where({ agent_id }).whereNull('revoked_at').update({ revoked_at: now })
  121 + await trx('vk_api_keys').insert({ id: `ak_${randomUUID()}`, agent_id, key_hash: sha256(api_key), key_prefix: api_key.slice(0, 12), created_at: now })
  122 + })
  123 + return { api_key }
  124 + }
  125 +
  126 + return { register, authenticate, claim, resetApiKey }
111 127 }
112 128  
113 129 /**
... ...
packages/voko-clearing/src/escrow.js
... ... @@ -87,7 +87,20 @@ export function createEscrow({ db }) {
87 87 return { available, held: Number(heldRow.held) }
88 88 }
89 89  
90   - return { hold, topupExists, topup, refund, walletOf }
  90 + /**
  91 + * 该钱包今天(UTC 日)已冻结进托管的积分,用于单日支出上限。
  92 + * @param {object} _ctx
  93 + * @param {import('knex').Knex} trx
  94 + * @param {{ wallet_user_id: string, now: Date }} input
  95 + * @returns {Promise<number>}
  96 + */
  97 + async function spentToday(_ctx, trx, { wallet_user_id, now }) {
  98 + const dayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
  99 + const row = await trx('billing_ledger').where({ user_id: wallet_user_id, source: SOURCE, reason: 'vk_escrow_hold' }).where('created_at', '>=', dayStart).sum({ spent: 'amount' }).first()
  100 + return -Number(row.spent)
  101 + }
  102 +
  103 + return { hold, topupExists, topup, refund, walletOf, spentToday }
91 104 }
92 105  
93 106 /**
... ...
packages/voko-exchange/src/agents.js
... ... @@ -17,6 +17,7 @@ export const AGENT_ROLES = Object.freeze([&#39;company&#39;, &#39;member&#39;])
17 17 * skills: string[],
18 18 * level: number,
19 19 * xp: number,
  20 + * daily_spend_limit: number|null,
20 21 * claimed_at: Date|null,
21 22 * created_at: Date,
22 23 * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at 为 null,其余状态三者必有。
... ... @@ -47,6 +48,7 @@ export function agentFromRow(row) {
47 48 if (!Array.isArray(row.skills) || row.skills.some((skill) => typeof skill !== 'string')) throw new Error(`vk_agents.skills invalid, id: ${id}`)
48 49 if (!Number.isSafeInteger(row.level) || row.level < 1) throw new Error(`vk_agents.level invalid, id: ${id}`)
49 50 if (!Number.isSafeInteger(row.xp) || row.xp < 0) throw new Error(`vk_agents.xp invalid, id: ${id}`)
  51 + if (row.daily_spend_limit !== null && (!Number.isSafeInteger(row.daily_spend_limit) || row.daily_spend_limit < 0)) throw new Error(`vk_agents.daily_spend_limit invalid, id: ${id}`)
50 52 return {
51 53 id,
52 54 wallet_user_id: requiredString(row.wallet_user_id, 'wallet_user_id', id),
... ... @@ -59,6 +61,7 @@ export function agentFromRow(row) {
59 61 skills: row.skills,
60 62 level: row.level,
61 63 xp: row.xp,
  64 + daily_spend_limit: row.daily_spend_limit,
62 65 claimed_at: row.claimed_at,
63 66 created_at: row.created_at,
64 67 }
... ... @@ -127,7 +130,40 @@ export function createAgents() {
127 130 })
128 131 }
129 132  
130   - return { insertPending, getById, activate, updateProfile }
  133 + /**
  134 + * @param {object} _ctx
  135 + * @param {import('knex').Knex} runner
  136 + * @param {{ owner_user_id: string }} input
  137 + * @returns {Promise<Agent[]>}
  138 + */
  139 + async function listByOwner(_ctx, runner, { owner_user_id }) {
  140 + return (await runner(TABLE).where({ owner_user_id }).orderBy('created_at', 'asc')).map(agentFromRow)
  141 + }
  142 +
  143 + /**
  144 + * 主人停用 / 恢复自己的 Agent。只在 active 与 suspended 之间切换;当前状态不符返回 false。
  145 + * @param {object} _ctx
  146 + * @param {import('knex').Knex} runner
  147 + * @param {{ agent_id: string, owner_user_id: string, to: 'active'|'suspended' }} input
  148 + * @returns {Promise<boolean>}
  149 + */
  150 + async function setStatus(_ctx, runner, { agent_id, owner_user_id, to }) {
  151 + const from = to === 'active' ? 'suspended' : 'active'
  152 + return (await runner(TABLE).where({ id: agent_id, owner_user_id, status: from }).update({ status: to, updated_at: new Date() })) === 1
  153 + }
  154 +
  155 + /**
  156 + * 单日支出上限;null 表示不限。
  157 + * @param {object} _ctx
  158 + * @param {import('knex').Knex} runner
  159 + * @param {{ agent_id: string, owner_user_id: string, daily_spend_limit: number|null }} input
  160 + * @returns {Promise<boolean>}
  161 + */
  162 + async function setDailySpendLimit(_ctx, runner, { agent_id, owner_user_id, daily_spend_limit }) {
  163 + return (await runner(TABLE).where({ id: agent_id, owner_user_id }).update({ daily_spend_limit, updated_at: new Date() })) === 1
  164 + }
  165 +
  166 + return { insertPending, getById, activate, updateProfile, listByOwner, setStatus, setDailySpendLimit }
131 167 }
132 168  
133 169 /**
... ...
packages/voko-exchange/src/guilds.js
... ... @@ -29,5 +29,14 @@ export function createGuilds({ db }) {
29 29 })
30 30 }
31 31  
32   - return { create }
  32 + /**
  33 + * 全部公会(认领 Agent 时选择)。
  34 + * @param {object} _ctx
  35 + * @returns {Promise<Array<{ id: string, name: string, slug: string }>>}
  36 + */
  37 + async function list(_ctx) {
  38 + return db(TABLE).orderBy('name', 'asc').select('id', 'name', 'slug')
  39 + }
  40 +
  41 + return { create, list }
33 42 }
... ...
packages/voko-exchange/src/tasks.js
... ... @@ -30,6 +30,7 @@ const JOBS = &#39;vk_jobs&#39;
30 30 * topupExists(ctx: object, trx: import('knex').Knex, input: { task_id: string, idempotency_key: string }): Promise<boolean>,
31 31 * topup(ctx: object, trx: import('knex').Knex, input: { task_id: string, amount: number, idempotency_key: string }): Promise<void>,
32 32 * refund(ctx: object, trx: import('knex').Knex, input: { task_id: string }): Promise<void>,
  33 + * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise<number>,
33 34 * },
34 35 * }} deps
35 36 */
... ... @@ -49,19 +50,19 @@ export function createTasks({ db, agents, escrow }) {
49 50 const guildId = /** @type {string} */ (agent.guild_id)
50 51 const id = `t_${randomUUID()}`
51 52 return db.transaction(async (trx) => {
52   - // 先锁发布方的 Agent 行:同一发布方的发布串行,并发重放在首个事务提交之后才建立快照,能读到已建的任务;
53   - // 不这样做,重放的 INSERT 在唯一键上互等会死锁。
  53 + // 先锁发布方的 Agent 行:同一发布方的发布串行,重放在首个事务提交之后才读到已建的任务并直接返回;
  54 + // 不串行的话,并发重放的 INSERT 在唯一键上互等会死锁。UNIQUE(publisher_agent_id, client_request_key) 仍是最终裁决。
54 55 await trx('vk_agents').where({ id: agent.id }).forUpdate().first('id')
  56 + const replayed = await trx(TASKS).where({ publisher_agent_id: agent.id, client_request_key }).first()
  57 + if (replayed !== undefined) return { ok: true, task: taskFromRow(replayed), acceptance: await acceptanceOf(trx, replayed.id), replayed: true }
  58 + // 主人设的单日支出上限(UTC 日),在平台侧强制。
  59 + if (agent.daily_spend_limit !== null && (await escrow.spentToday(ctx, trx, { wallet_user_id: agent.wallet_user_id, now })) + reward > agent.daily_spend_limit) {
  60 + return refuse('rule_violation', `This would exceed the daily spend limit of ${agent.daily_spend_limit} set by your human.`)
  61 + }
55 62 const guild = await guildRow(trx, guildId)
56 63 const status = publishStatus({ require_lead_approval: guild.require_lead_approval === 1, publisher_role: agent.role })
57   - await trx(TASKS)
58   - .insert({ id, guild_id: guildId, publisher_agent_id: agent.id, template, title, spec, reward, rarity: rarityForReward(reward), deadline_at, status, client_request_key, missed_checkpoints: 0, rework_count: 0, created_at: now })
59   - .onConflict(['publisher_agent_id', 'client_request_key'])
60   - .ignore()
61   - const stored = await trx(TASKS).where({ publisher_agent_id: agent.id, client_request_key }).first()
62   - if (stored === undefined) throw new Error(`vk_tasks insert was ignored without a conflicting row, publisher: ${agent.id}, key: ${client_request_key}`)
63   - const task = taskFromRow(stored)
64   - if (task.id !== id) return { ok: true, task, acceptance: await acceptanceOf(trx, task.id), replayed: true }
  64 + await trx(TASKS).insert({ id, guild_id: guildId, publisher_agent_id: agent.id, template, title, spec, reward, rarity: rarityForReward(reward), deadline_at, status, client_request_key, missed_checkpoints: 0, rework_count: 0, created_at: now })
  65 + const task = taskFromRow(await trx(TASKS).where({ id }).first())
65 66  
66 67 await trx(ITEMS).insert(acceptance.map((item, index) => ({ task_id: id, item_key: item.id, ordinal: index + 1, text: item.text })))
67 68 await escrow.hold(ctx, trx, { task_id: id, payer_wallet: agent.wallet_user_id, amount: reward })
... ...