init
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Live Show - AI剧本杀</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "hermes-live-show",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"vue": "^3.4.38",
|
||||
"vue-router": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"typescript": "~5.5.4",
|
||||
"vite": "^5.4.3",
|
||||
"vue-tsc": "^2.1.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#6c63ff"/>
|
||||
<stop offset="100%" style="stop-color:#4ecdc4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#g)"/>
|
||||
<text x="50" y="62" text-anchor="middle" font-size="40" font-weight="bold" fill="white" font-family="sans-serif">H</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 457 B |
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div id="app-root">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="character-card" :class="{ selected: isSelected, dead: character.status === 'dead' }" @click="$emit('select')">
|
||||
<div class="card-avatar">
|
||||
<div class="avatar-placeholder">{{ character.name[0] }}</div>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-name">{{ character.name }}</div>
|
||||
<div class="card-role">{{ roleLabel }}</div>
|
||||
</div>
|
||||
<div class="card-status" v-if="character.status === 'dead'">
|
||||
<span class="dead-badge">已出局</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Character } from '../../types/character'
|
||||
|
||||
const props = defineProps<{
|
||||
character: Character
|
||||
isSelected: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: []
|
||||
}>()
|
||||
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
detective: '🔍 侦探',
|
||||
suspect: '🤔 嫌疑人',
|
||||
witness: '👁️ 目击者',
|
||||
victim: '💀 被害人',
|
||||
killer: '🔪 凶手',
|
||||
}
|
||||
|
||||
const roleLabel = computed(() => ROLE_MAP[props.character.role] || props.character.role)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.character-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.character-card.selected {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
}
|
||||
|
||||
.character-card.dead {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dead-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="character-list">
|
||||
<div class="list-header">
|
||||
<h3>登场角色</h3>
|
||||
<span class="count-badge">{{ characters.length }}</span>
|
||||
</div>
|
||||
<div class="list-body">
|
||||
<CharacterCard
|
||||
v-for="char in characters"
|
||||
:key="char.id"
|
||||
:character="char"
|
||||
:is-selected="char.id === selectedId"
|
||||
@select="$emit('select-character', char.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Character } from '../../types/character'
|
||||
import CharacterCard from './CharacterCard.vue'
|
||||
|
||||
defineProps<{
|
||||
characters: Character[]
|
||||
selectedId: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'select-character': [id: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-header h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.list-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="chat-log" ref="chatContainer">
|
||||
<div class="chat-messages" v-if="messages.length > 0">
|
||||
<MessageBubble
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
</div>
|
||||
<div class="chat-empty" v-else>
|
||||
<div class="empty-icon">💬</div>
|
||||
<p>暂无消息,等待角色互动...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, ref, nextTick } from 'vue'
|
||||
import type { Message } from '../../types/message'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[]
|
||||
}>()
|
||||
|
||||
const chatContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (chatContainer.value) {
|
||||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-log {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="clue-board">
|
||||
<div class="board-header">
|
||||
<h3>📋 线索本</h3>
|
||||
<span class="clue-count">{{ unlockedCount }}/{{ clues.length }}</span>
|
||||
</div>
|
||||
<div class="board-body">
|
||||
<div v-if="clues.length === 0" class="board-empty">暂无线索</div>
|
||||
<div
|
||||
v-for="clue in clues"
|
||||
:key="clue.id"
|
||||
class="clue-item"
|
||||
:class="{ unlocked: clue.is_unlocked }"
|
||||
>
|
||||
<div class="clue-header">
|
||||
<span class="clue-type">{{ TYPE_ICONS[clue.clue_type] || '📌' }}</span>
|
||||
<span class="clue-name">{{ clue.name }}</span>
|
||||
<span v-if="!clue.is_unlocked" class="clue-hidden">🔒</span>
|
||||
<span v-else class="clue-unlocked">🔓</span>
|
||||
</div>
|
||||
<div v-if="clue.is_unlocked" class="clue-desc">{{ clue.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Clue } from '../../stores/clueStore'
|
||||
|
||||
const props = defineProps<{
|
||||
clues: Clue[]
|
||||
}>()
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
physical: '🔧',
|
||||
testimony: '💬',
|
||||
motive: '💰',
|
||||
alibi: '⏰',
|
||||
forensic: '🔬',
|
||||
}
|
||||
|
||||
const unlockedCount = computed(() => props.clues.filter((c) => c.is_unlocked).length)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clue-board { display: flex; flex-direction: column; height: 100%; }
|
||||
.board-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-color); }
|
||||
.board-header h3 { font-size: 0.9rem; font-weight: 600; }
|
||||
.clue-count { font-size: 0.7rem; color: var(--text-secondary); }
|
||||
.board-body { flex: 1; overflow-y: auto; padding: 0.5rem; }
|
||||
.board-empty { display: flex; align-items: center; justify-content: center; height: 80px; color: var(--text-muted); font-size: 0.8rem; }
|
||||
.clue-item { padding: 0.5rem; margin-bottom: 0.5rem; border-radius: var(--border-radius); background: var(--bg-card); border: 1px solid var(--border-color); transition: all 0.3s ease; }
|
||||
.clue-item.unlocked { border-color: var(--accent-success); background: rgba(107, 203, 119, 0.05); }
|
||||
.clue-header { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.clue-name { flex: 1; font-size: 0.85rem; font-weight: 500; }
|
||||
.clue-desc { margin-top: 0.4rem; padding-top: 0.4rem; border-top: 1px solid var(--border-color); font-size: 0.8rem; color: var(--text-secondary); line-height: 1.5; }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div
|
||||
class="message-bubble"
|
||||
:class="[
|
||||
`msg-${message.msg_type}`,
|
||||
{ 'is-self': false }
|
||||
]"
|
||||
>
|
||||
<div class="bubble-header" v-if="message.msg_type !== 'system' && message.msg_type !== 'narrator'">
|
||||
<span class="character-name">{{ message.character_name || '未知角色' }}</span>
|
||||
<span class="role-badge" :class="message.character_role">
|
||||
{{ roleLabel(message.character_role) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bubble-content">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<div class="bubble-time">
|
||||
{{ formatTime(message.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '../../types/message'
|
||||
import type { CharacterRole } from '../../types/game'
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message
|
||||
}>()
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
detective: '侦探',
|
||||
suspect: '嫌疑人',
|
||||
witness: '目击者',
|
||||
victim: '被害人',
|
||||
killer: '凶手',
|
||||
}
|
||||
|
||||
function roleLabel(role?: CharacterRole): string {
|
||||
return role ? ROLE_LABELS[role] || role : '未知'
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-bubble {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 0.5rem;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.msg-system {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg-narrator {
|
||||
background: linear-gradient(135deg, #1a1a3e, #2a1a3e);
|
||||
border-left: 3px solid var(--accent-secondary);
|
||||
}
|
||||
|
||||
.msg-character {
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.msg-vote {
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid var(--accent-warning);
|
||||
}
|
||||
|
||||
.msg-clue {
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid var(--accent-success);
|
||||
}
|
||||
|
||||
.bubble-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(108, 99, 255, 0.2);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.role-badge.killer {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.role-badge.detective {
|
||||
background: rgba(78, 205, 196, 0.2);
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bubble-time {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="phase-control">
|
||||
<div class="control-header">
|
||||
<h3>🎮 阶段控制</h3>
|
||||
</div>
|
||||
<div class="control-body">
|
||||
<button
|
||||
v-for="phase in phases"
|
||||
:key="phase.key"
|
||||
class="phase-btn"
|
||||
:class="{ active: phase.key === currentPhase }"
|
||||
@click="$emit('change-phase', phase.key)"
|
||||
>
|
||||
{{ phase.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button class="action-btn start-btn" @click="$emit('start-game')" v-if="!isRunning">
|
||||
▶ 开始游戏
|
||||
</button>
|
||||
<button class="action-btn pause-btn" @click="$emit('pause-game')" v-if="isRunning && !isPaused">
|
||||
⏸ 暂停
|
||||
</button>
|
||||
<button class="action-btn resume-btn" @click="$emit('resume-game')" v-if="isPaused">
|
||||
▶ 继续
|
||||
</button>
|
||||
<button class="action-btn start-btn" @click="$emit('start-auto')" v-if="isRunning && !autoActive">
|
||||
🤖 自动发言
|
||||
</button>
|
||||
<button class="action-btn stop-btn" @click="$emit('stop-auto')" v-if="autoActive">
|
||||
⏹ 停止
|
||||
</button>
|
||||
<button class="action-btn vote-btn" @click="$emit('trigger-vote')" v-if="isRunning">
|
||||
🗳 投票
|
||||
</button>
|
||||
<button class="action-btn reset-btn" @click="$emit('reset-game')">
|
||||
🔄 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GamePhase } from '../../types/game'
|
||||
|
||||
defineProps<{
|
||||
currentPhase: GamePhase
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
autoActive: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'change-phase': [phase: GamePhase]
|
||||
'start-game': []
|
||||
'pause-game': []
|
||||
'resume-game': []
|
||||
'start-auto': []
|
||||
'stop-auto': []
|
||||
'trigger-vote': []
|
||||
'reset-game': []
|
||||
}>()
|
||||
|
||||
const phases = [
|
||||
{ key: 'intro' as GamePhase, label: '🎬 开场介绍' },
|
||||
{ key: 'round1_speak' as GamePhase, label: '💬 第一轮发言' },
|
||||
{ key: 'round1_search' as GamePhase, label: '🔍 第一轮搜证' },
|
||||
{ key: 'round2_speak' as GamePhase, label: '💬 第二轮发言' },
|
||||
{ key: 'round2_search' as GamePhase, label: '🔍 第二轮搜证' },
|
||||
{ key: 'final_discuss' as GamePhase, label: '🗣 最终讨论' },
|
||||
{ key: 'voting' as GamePhase, label: '🗳 投票' },
|
||||
{ key: 'reveal' as GamePhase, label: '🔎 揭晓真凶' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.phase-control { padding: 1rem; }
|
||||
.control-header { margin-bottom: 0.75rem; }
|
||||
.control-header h3 { font-size: 0.9rem; font-weight: 600; }
|
||||
.control-body { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
|
||||
|
||||
.phase-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.phase-btn:hover { border-color: var(--accent-primary); }
|
||||
.phase-btn.active { background: var(--accent-primary); border-color: var(--accent-primary); color: white; }
|
||||
|
||||
.control-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.start-btn { background: var(--accent-success); color: #fff; }
|
||||
.start-btn:hover { opacity: 0.85; }
|
||||
.pause-btn { background: var(--accent-warning); color: #1a1a2e; }
|
||||
.pause-btn:hover { opacity: 0.85; }
|
||||
.resume-btn { background: var(--accent-secondary); color: #fff; }
|
||||
.resume-btn:hover { opacity: 0.85; }
|
||||
.stop-btn { background: var(--accent-danger); color: #fff; }
|
||||
.stop-btn:hover { opacity: 0.85; }
|
||||
.vote-btn { background: var(--accent-warning); color: #1a1a2e; }
|
||||
.vote-btn:hover { opacity: 0.85; }
|
||||
.reset-btn { background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||
.reset-btn:hover { border-color: var(--accent-danger); color: var(--accent-danger); }
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="progress-bar">
|
||||
<div class="phase-steps">
|
||||
<div
|
||||
v-for="phase in phases"
|
||||
:key="phase.key"
|
||||
class="phase-step"
|
||||
:class="{
|
||||
completed: phaseOrder.indexOf(phase.key) < phaseOrder.indexOf(currentPhase),
|
||||
active: phase.key === currentPhase,
|
||||
upcoming: phaseOrder.indexOf(phase.key) > phaseOrder.indexOf(currentPhase),
|
||||
}"
|
||||
>
|
||||
<div class="step-dot">
|
||||
<span v-if="phaseOrder.indexOf(phase.key) < phaseOrder.indexOf(currentPhase)">✓</span>
|
||||
<span v-else-if="phase.key === currentPhase">{{ phaseOrder.indexOf(currentPhase) + 1 }}</span>
|
||||
</div>
|
||||
<div class="step-label">{{ phase.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: progressPercent + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { GamePhase } from '../../types/game'
|
||||
|
||||
const props = defineProps<{
|
||||
currentPhase: GamePhase
|
||||
}>()
|
||||
|
||||
const phases = [
|
||||
{ key: 'intro' as GamePhase, label: '开场' },
|
||||
{ key: 'round1_speak' as GamePhase, label: '发言1' },
|
||||
{ key: 'round1_search' as GamePhase, label: '搜证1' },
|
||||
{ key: 'round2_speak' as GamePhase, label: '发言2' },
|
||||
{ key: 'round2_search' as GamePhase, label: '搜证2' },
|
||||
{ key: 'final_discuss' as GamePhase, label: '讨论' },
|
||||
{ key: 'voting' as GamePhase, label: '投票' },
|
||||
{ key: 'reveal' as GamePhase, label: '揭晓' },
|
||||
]
|
||||
|
||||
const phaseOrder: GamePhase[] = phases.map((p) => p.key)
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const idx = phaseOrder.indexOf(props.currentPhase)
|
||||
return idx >= 0 ? (idx / (phaseOrder.length - 1)) * 100 : 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-bar { padding: 1rem; }
|
||||
.phase-steps { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||
.phase-step { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; opacity: 0.4; transition: opacity 0.3s ease; }
|
||||
.phase-step.completed { opacity: 0.6; }
|
||||
.phase-step.active { opacity: 1; }
|
||||
.step-dot {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: var(--bg-card); border: 2px solid var(--border-color);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.65rem; font-weight: 700; transition: all 0.3s ease;
|
||||
}
|
||||
.phase-step.completed .step-dot { background: var(--accent-success); border-color: var(--accent-success); color: white; }
|
||||
.phase-step.active .step-dot { background: var(--accent-primary); border-color: var(--accent-primary); color: white; }
|
||||
.step-label { font-size: 0.55rem; text-align: center; white-space: nowrap; }
|
||||
.progress-track { height: 4px; background: var(--border-color); border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 2px; transition: width 0.5s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" v-if="visible" @click.self="$emit('close')">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ character.name }}</h2>
|
||||
<button class="modal-close" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body" v-if="character">
|
||||
<div class="info-section">
|
||||
<div class="info-row">
|
||||
<span class="info-label">角色定位</span>
|
||||
<span class="info-value">{{ roleLabel }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value" :class="character.status">{{ statusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h4>性格特征</h4>
|
||||
<p>{{ character.personality || '暂无设定' }}</p>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h4>人物背景</h4>
|
||||
<p>{{ character.background || '暂无设定' }}</p>
|
||||
</div>
|
||||
<div class="info-section" v-if="character.soul_md">
|
||||
<h4>SOUL.md</h4>
|
||||
<pre class="soul-content">{{ character.soul_md }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Character } from '../../types/character'
|
||||
|
||||
const props = defineProps<{
|
||||
character: Character | null
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
detective: '侦探',
|
||||
suspect: '嫌疑人',
|
||||
witness: '目击者',
|
||||
victim: '被害人',
|
||||
killer: '凶手',
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
alive: '✅ 存活',
|
||||
dead: '💀 已出局',
|
||||
inactive: '😴 未激活',
|
||||
}
|
||||
|
||||
const roleLabel = computed(() => props.character ? ROLE_MAP[props.character.role] || props.character.role : '')
|
||||
const statusLabel = computed(() => props.character ? STATUS_MAP[props.character.status] || props.character.status : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.info-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-section h4 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value.dead {
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.soul-content {
|
||||
background: var(--bg-primary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
import LiveView from './views/LiveView.vue'
|
||||
import ControlView from './views/ControlView.vue'
|
||||
import ScriptImportView from './views/ScriptImportView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/live' },
|
||||
{ path: '/live', component: LiveView },
|
||||
{ path: '/control', component: ControlView },
|
||||
{ path: '/script-import', component: ScriptImportView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,32 @@
|
||||
const BASE_URL = '/api'
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${BASE_URL}${path}`
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...options,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
throw new Error(`API Error ${response.status}: ${err}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: <T>(path: string) =>
|
||||
request<T>(path, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
const SOCKET_URL = window.location.origin
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
export function connectSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io(SOCKET_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 10,
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[Socket] Connected:', socket?.id)
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('[Socket] Disconnected:', reason)
|
||||
})
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.warn('[Socket] Connection error:', err.message)
|
||||
})
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
export function getSocket(): Socket | null {
|
||||
return socket
|
||||
}
|
||||
|
||||
export function disconnectSocket(): void {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { Character, CharacterGenerateRequest } from '../types/character'
|
||||
|
||||
export const useCharacterStore = defineStore('character', () => {
|
||||
const characters = ref<Character[]>([])
|
||||
const selectedCharacterId = ref<string | null>(null)
|
||||
|
||||
const selectedCharacter = computed(() =>
|
||||
characters.value.find((c) => c.id === selectedCharacterId.value) ?? null
|
||||
)
|
||||
|
||||
const characterMap = computed(() => {
|
||||
const map: Record<string, Character> = {}
|
||||
characters.value.forEach((c) => { map[c.id] = c })
|
||||
return map
|
||||
})
|
||||
|
||||
async function fetchCharacters() {
|
||||
characters.value = await api.get<Character[]>('/characters')
|
||||
}
|
||||
|
||||
async function generateCharacters(scriptId: string, names: string[]) {
|
||||
characters.value = await api.post<Character[]>(
|
||||
'/characters/generate',
|
||||
{ script_id: scriptId, character_names: names } as CharacterGenerateRequest
|
||||
)
|
||||
}
|
||||
|
||||
async function createProfile(characterId: string) {
|
||||
await api.post(`/characters/${characterId}/profile`)
|
||||
}
|
||||
|
||||
async function speakCharacter(characterId: string, prompt: string, targetId?: string) {
|
||||
const result = await api.post<{ character_id: string; character_name: string; response: string }>(
|
||||
`/characters/${characterId}/speak`,
|
||||
{ prompt, target_character_id: targetId }
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function selectCharacter(id: string | null) {
|
||||
selectedCharacterId.value = id
|
||||
}
|
||||
|
||||
function clearCharacters() {
|
||||
characters.value = []
|
||||
selectedCharacterId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
characters,
|
||||
selectedCharacterId,
|
||||
selectedCharacter,
|
||||
characterMap,
|
||||
fetchCharacters,
|
||||
generateCharacters,
|
||||
createProfile,
|
||||
speakCharacter,
|
||||
selectCharacter,
|
||||
clearCharacters,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { ClueType, ClueVisibility } from '../types/game'
|
||||
|
||||
export interface Clue {
|
||||
id: string
|
||||
script_id: string
|
||||
name: string
|
||||
content: string
|
||||
clue_type: ClueType
|
||||
owner_id: string | null
|
||||
phase: string
|
||||
visibility: ClueVisibility
|
||||
visible_to: string[]
|
||||
is_unlocked: boolean
|
||||
unlocked_by: string | null
|
||||
unlocked_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ClueCreateRequest {
|
||||
script_id: string
|
||||
name: string
|
||||
content: string
|
||||
clue_type: ClueType
|
||||
owner_id?: string
|
||||
phase?: string
|
||||
visibility?: ClueVisibility
|
||||
visible_to?: string[]
|
||||
}
|
||||
|
||||
export const useClueStore = defineStore('clue', () => {
|
||||
const clues = ref<Clue[]>([])
|
||||
|
||||
async function fetchClues(scriptId: string) {
|
||||
clues.value = await api.get<Clue[]>(`/${scriptId}/clues`)
|
||||
}
|
||||
|
||||
async function createClue(req: ClueCreateRequest) {
|
||||
const clue = await api.post<Clue>(`/${req.script_id}/clues`, req)
|
||||
clues.value.push(clue)
|
||||
return clue
|
||||
}
|
||||
|
||||
async function unlockClue(clueId: string, characterId: string) {
|
||||
const result = await api.post<{ ok: boolean; clue: Clue }>(
|
||||
`/clues/${clueId}/unlock`,
|
||||
{ character_id: characterId }
|
||||
)
|
||||
const idx = clues.value.findIndex((c) => c.id === clueId)
|
||||
if (idx !== -1 && result.clue) {
|
||||
clues.value[idx] = result.clue
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClue(clueId: string) {
|
||||
await api.delete(`/clues/${clueId}`)
|
||||
clues.value = clues.value.filter((c) => c.id !== clueId)
|
||||
}
|
||||
|
||||
function clearClues() {
|
||||
clues.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
clues,
|
||||
fetchClues,
|
||||
createClue,
|
||||
unlockClue,
|
||||
deleteClue,
|
||||
clearClues,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { GamePhase } from '../types/game'
|
||||
|
||||
interface GameState {
|
||||
id: string
|
||||
current_phase: GamePhase
|
||||
current_speaker_id: string | null
|
||||
speaker_order: string[]
|
||||
active_script_id: string | null
|
||||
is_running: boolean
|
||||
is_paused: boolean
|
||||
phase_started_at: string | null
|
||||
config: Record<string, any>
|
||||
progress_percent: number
|
||||
}
|
||||
|
||||
export const useGameStore = defineStore('game', () => {
|
||||
const state = ref<GameState>({
|
||||
id: '',
|
||||
current_phase: 'intro',
|
||||
current_speaker_id: null,
|
||||
speaker_order: [],
|
||||
active_script_id: null,
|
||||
is_running: false,
|
||||
is_paused: false,
|
||||
phase_started_at: null,
|
||||
config: {},
|
||||
progress_percent: 0,
|
||||
})
|
||||
|
||||
const autoChatActive = ref(false)
|
||||
|
||||
const currentPhase = computed(() => state.value.current_phase)
|
||||
const isRunning = computed(() => state.value.is_running)
|
||||
const activeScriptId = computed(() => state.value.active_script_id)
|
||||
|
||||
async function fetchState() {
|
||||
state.value = await api.get<GameState>('/game/state')
|
||||
}
|
||||
|
||||
async function nextPhase() {
|
||||
state.value = await api.post<GameState>('/game/phase/next')
|
||||
}
|
||||
|
||||
async function prevPhase() {
|
||||
state.value = await api.post<GameState>('/game/phase/prev')
|
||||
}
|
||||
|
||||
async function resetGame() {
|
||||
state.value = await api.post<GameState>('/game/reset')
|
||||
}
|
||||
|
||||
async function startGame(scriptId: string) {
|
||||
state.value = await api.post<GameState>('/game/start', { script_id: scriptId })
|
||||
}
|
||||
|
||||
async function pauseGame() {
|
||||
state.value = await api.post<GameState>('/game/pause')
|
||||
}
|
||||
|
||||
async function resumeGame() {
|
||||
state.value = await api.post<GameState>('/game/resume')
|
||||
}
|
||||
|
||||
function setAutoChatActive(active: boolean) {
|
||||
autoChatActive.value = active
|
||||
}
|
||||
|
||||
function setState(newState: GameState) {
|
||||
state.value = newState
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
autoChatActive,
|
||||
currentPhase,
|
||||
isRunning,
|
||||
activeScriptId,
|
||||
fetchState,
|
||||
nextPhase,
|
||||
prevPhase,
|
||||
resetGame,
|
||||
startGame,
|
||||
pauseGame,
|
||||
resumeGame,
|
||||
setAutoChatActive,
|
||||
setState,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { Message } from '../types/message'
|
||||
|
||||
export const useMessageStore = defineStore('message', () => {
|
||||
const messages = ref<Message[]>([])
|
||||
|
||||
const lastMessage = computed(() =>
|
||||
messages.value.length > 0 ? messages.value[messages.value.length - 1] : null
|
||||
)
|
||||
|
||||
function addMessage(msg: Message) {
|
||||
messages.value.push(msg)
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
messages.value = await api.get<Message[]>('/messages')
|
||||
}
|
||||
|
||||
async function sendMessage(req: {
|
||||
session_id?: string
|
||||
character_id?: string
|
||||
game_phase: string
|
||||
msg_type: string
|
||||
content: string
|
||||
target_character_id?: string
|
||||
}) {
|
||||
const msg = await api.post<Message>('/messages', req)
|
||||
messages.value.push(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
lastMessage,
|
||||
addMessage,
|
||||
fetchMessages,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-card: #16213e;
|
||||
--bg-card-hover: #1c2a4a;
|
||||
--text-primary: #e8e8f0;
|
||||
--text-secondary: #a0a0b8;
|
||||
--text-muted: #6a6a80;
|
||||
--accent-primary: #6c63ff;
|
||||
--accent-secondary: #4ecdc4;
|
||||
--accent-danger: #ff6b6b;
|
||||
--accent-warning: #ffd93d;
|
||||
--accent-success: #6bcb77;
|
||||
--border-color: #2a2a40;
|
||||
--border-radius: 8px;
|
||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||
--font-sans: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CharacterRole, CharacterStatus } from './game'
|
||||
|
||||
export interface Character {
|
||||
id: string
|
||||
script_id: string
|
||||
name: string
|
||||
role: CharacterRole
|
||||
status: CharacterStatus
|
||||
personality: string
|
||||
speaking_style: string
|
||||
background: string
|
||||
secret: string
|
||||
motive: string
|
||||
avatar_url: string
|
||||
hermes_profile: string
|
||||
soul_md: string
|
||||
is_revealed_killer: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CharacterGenerateRequest {
|
||||
script_id: string
|
||||
character_names: string[]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type GamePhase =
|
||||
| 'intro'
|
||||
| 'round1_speak'
|
||||
| 'round1_search'
|
||||
| 'round2_speak'
|
||||
| 'round2_search'
|
||||
| 'final_discuss'
|
||||
| 'voting'
|
||||
| 'reveal'
|
||||
|
||||
export type CharacterRole =
|
||||
| 'detective'
|
||||
| 'suspect'
|
||||
| 'witness'
|
||||
| 'victim'
|
||||
| 'killer'
|
||||
|
||||
export type CharacterStatus = 'alive' | 'dead' | 'inactive'
|
||||
|
||||
export type MessageType = 'system' | 'dm' | 'character' | 'vote' | 'clue'
|
||||
|
||||
export type ClueType = 'physical' | 'testimony' | 'motive' | 'alibi' | 'forensic'
|
||||
|
||||
export type ClueVisibility = 'all' | 'specific' | 'hidden'
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { GamePhase, MessageType, CharacterRole } from './game'
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
session_id: string
|
||||
character_id: string | null
|
||||
game_phase: GamePhase
|
||||
msg_type: MessageType
|
||||
content: string
|
||||
target_character_id: string | null
|
||||
clue_id: string | null
|
||||
metadata: Record<string, any>
|
||||
created_at: string
|
||||
character_name?: string
|
||||
character_role?: CharacterRole
|
||||
}
|
||||
|
||||
export interface MessageCreateRequest {
|
||||
session_id?: string
|
||||
character_id?: string
|
||||
game_phase: string
|
||||
msg_type: string
|
||||
content: string
|
||||
target_character_id?: string
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface Script {
|
||||
id: string
|
||||
title: string
|
||||
background: string
|
||||
character_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ScriptParsePreview {
|
||||
title: string
|
||||
background: string
|
||||
characters: Array<{
|
||||
name: string
|
||||
personality: string
|
||||
speaking_style: string
|
||||
secret: string
|
||||
motive: string
|
||||
}>
|
||||
clues: Array<{
|
||||
id?: string
|
||||
content: string
|
||||
owner: string
|
||||
phase: string
|
||||
}>
|
||||
phases: Array<{
|
||||
name: string
|
||||
order: number
|
||||
duration: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ScriptImportRequest {
|
||||
title: string
|
||||
background: string
|
||||
characters: Array<Record<string, any>>
|
||||
clues: Array<Record<string, any>>
|
||||
phases: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
export interface ScriptUploadRequest {
|
||||
title: string
|
||||
content: string
|
||||
file_type: string
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="control-view">
|
||||
<div class="control-topbar">
|
||||
<div class="topbar-left">
|
||||
<h2>🎮 主播控制台</h2>
|
||||
<span class="connection-dot" :class="{ connected: socketConnected }"></span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<router-link to="/live" class="nav-link">📺 观众视角</router-link>
|
||||
<router-link to="/script-import" class="nav-link">📜 剧本管理</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-layout">
|
||||
<div class="panel panel-left">
|
||||
<section class="panel-section">
|
||||
<PhaseControl
|
||||
:current-phase="gameStore.currentPhase"
|
||||
:is-running="gameStore.isRunning"
|
||||
:is-paused="gameStore.state.is_paused"
|
||||
:auto-active="gameStore.autoChatActive"
|
||||
@change-phase="changePhase"
|
||||
@start-game="startGame"
|
||||
@pause-game="pauseGame"
|
||||
@resume-game="resumeGame"
|
||||
@start-auto="startAutoChat"
|
||||
@stop-auto="stopAutoChat"
|
||||
@trigger-vote="triggerVote"
|
||||
@reset-game="resetGame"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<ProgressBar :current-phase="gameStore.currentPhase" />
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="inject-section">
|
||||
<h3>✏️ 手动注入</h3>
|
||||
<div class="inject-form">
|
||||
<select v-model="injectCharacterId" class="form-select">
|
||||
<option value="">选择角色</option>
|
||||
<option v-for="char in characterStore.characters" :key="char.id" :value="char.id">
|
||||
{{ char.name }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="injectPrompt"
|
||||
type="text"
|
||||
placeholder="输入提示词,让角色回应..."
|
||||
class="form-input"
|
||||
@keyup.enter="injectMessage"
|
||||
/>
|
||||
<button class="inject-btn" @click="injectMessage" :disabled="!injectCharacterId || !injectPrompt">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-center">
|
||||
<div class="chat-container">
|
||||
<ChatLog :messages="messageStore.messages" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-right">
|
||||
<section class="panel-section">
|
||||
<CharacterList
|
||||
:characters="characterStore.characters"
|
||||
:selected-id="characterStore.selectedCharacterId"
|
||||
@select-character="selectCharacter"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="quick-actions">
|
||||
<button class="qa-btn" @click="createAllProfiles">🎭 生成Profile</button>
|
||||
<button class="qa-btn" @click="refreshData">🔄 刷新</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<ClueBoard :clues="clueStore.clues" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoleModal
|
||||
:character="modalCharacter"
|
||||
:visible="modalVisible"
|
||||
@close="modalVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useMessageStore } from '../stores/messageStore'
|
||||
import { useCharacterStore } from '../stores/characterStore'
|
||||
import { useClueStore } from '../stores/clueStore'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import { connectSocket, disconnectSocket, getSocket } from '../services/socket'
|
||||
import ChatLog from '../components/ChatLog.vue'
|
||||
import CharacterList from '../components/CharacterList.vue'
|
||||
import ProgressBar from '../components/ProgressBar.vue'
|
||||
import ClueBoard from '../components/ClueBoard.vue'
|
||||
import PhaseControl from '../components/PhaseControl.vue'
|
||||
import RoleModal from '../components/RoleModal.vue'
|
||||
import type { Message } from '../types/message'
|
||||
import type { Character } from '../types/character'
|
||||
import type { GamePhase } from '../types/game'
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const characterStore = useCharacterStore()
|
||||
const clueStore = useClueStore()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const socketConnected = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const modalCharacter = ref<Character | null>(null)
|
||||
const injectCharacterId = ref('')
|
||||
const injectPrompt = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const socket = connectSocket()
|
||||
socket.on('connect', () => { socketConnected.value = true })
|
||||
socket.on('disconnect', () => { socketConnected.value = false })
|
||||
|
||||
socket.on('new_message', (msg: Message) => { messageStore.addMessage(msg) })
|
||||
socket.on('state_change', (data: { phase: GamePhase; progress: number }) => {
|
||||
gameStore.state.current_phase = data.phase
|
||||
gameStore.state.progress_percent = data.progress
|
||||
})
|
||||
socket.on('clue_unlocked', () => { refreshData() })
|
||||
socket.on('speaker_change', (data: { current_speaker_id: string | null }) => {
|
||||
gameStore.state.current_speaker_id = data.current_speaker_id
|
||||
})
|
||||
|
||||
await gameStore.fetchState()
|
||||
if (gameStore.activeScriptId) {
|
||||
await loadScriptData(gameStore.activeScriptId)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => { disconnectSocket() })
|
||||
|
||||
async function loadScriptData(scriptId: string) {
|
||||
await Promise.all([
|
||||
characterStore.fetchCharacters(),
|
||||
messageStore.fetchMessages(),
|
||||
clueStore.fetchClues(scriptId),
|
||||
])
|
||||
}
|
||||
|
||||
async function selectCharacter(id: string) {
|
||||
characterStore.selectCharacter(id)
|
||||
modalCharacter.value = characterStore.characters.find((c) => c.id === id) ?? null
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function changePhase(phase: GamePhase) {
|
||||
if (phase === gameStore.currentPhase) return
|
||||
if (phase === 'intro') {
|
||||
getSocket()?.emit('dm_command', { command: 'prev_phase' })
|
||||
} else {
|
||||
getSocket()?.emit('dm_command', { command: 'next_phase' })
|
||||
}
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
const scriptId = gameStore.activeScriptId
|
||||
if (!scriptId) return
|
||||
await gameStore.startGame(scriptId)
|
||||
getSocket()?.emit('dm_command', { command: 'next_phase' })
|
||||
}
|
||||
|
||||
async function pauseGame() { await gameStore.pauseGame() }
|
||||
async function resumeGame() { await gameStore.resumeGame() }
|
||||
|
||||
async function resetGame() {
|
||||
await gameStore.resetGame()
|
||||
messageStore.clearMessages()
|
||||
}
|
||||
|
||||
function startAutoChat() {
|
||||
gameStore.setAutoChatActive(true)
|
||||
getSocket()?.emit('dm_command', {
|
||||
command: 'start_auto',
|
||||
args: { script_id: gameStore.activeScriptId, interval: 8, rounds: 1 },
|
||||
})
|
||||
}
|
||||
|
||||
function stopAutoChat() {
|
||||
gameStore.setAutoChatActive(false)
|
||||
getSocket()?.emit('dm_command', { command: 'stop_auto' })
|
||||
}
|
||||
|
||||
function triggerVote() {
|
||||
getSocket()?.emit('dm_command', {
|
||||
command: 'trigger_vote',
|
||||
args: { script_id: gameStore.activeScriptId },
|
||||
})
|
||||
}
|
||||
|
||||
async function injectMessage() {
|
||||
if (!injectCharacterId.value || !injectPrompt.value) return
|
||||
const result = await characterStore.speakCharacter(injectCharacterId.value, injectPrompt.value)
|
||||
if (result) {
|
||||
messageStore.addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
session_id: '',
|
||||
character_id: injectCharacterId.value,
|
||||
game_phase: gameStore.currentPhase,
|
||||
msg_type: 'dm',
|
||||
content: injectPrompt.value,
|
||||
target_character_id: null,
|
||||
clue_id: null,
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
character_name: '主持人',
|
||||
})
|
||||
messageStore.addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
session_id: '',
|
||||
character_id: injectCharacterId.value,
|
||||
game_phase: gameStore.currentPhase,
|
||||
msg_type: 'character',
|
||||
content: result.response,
|
||||
target_character_id: null,
|
||||
clue_id: null,
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
character_name: result.character_name,
|
||||
})
|
||||
}
|
||||
injectPrompt.value = ''
|
||||
}
|
||||
|
||||
async function createAllProfiles() {
|
||||
for (const char of characterStore.characters) {
|
||||
await characterStore.createProfile(char.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
if (gameStore.activeScriptId) {
|
||||
await loadScriptData(gameStore.activeScriptId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.control-view { display: flex; flex-direction: column; height: 100%; }
|
||||
.control-topbar { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 1.5rem; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); }
|
||||
.topbar-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.topbar-left h2 { font-size: 1.1rem; font-weight: 600; }
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-danger); transition: background 0.3s; }
|
||||
.connection-dot.connected { background: var(--accent-success); }
|
||||
.topbar-right { display: flex; gap: 1rem; }
|
||||
.nav-link { color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; padding: 0.3rem 0.6rem; border-radius: 4px; transition: all 0.2s; }
|
||||
.nav-link:hover { color: var(--accent-primary); background: rgba(108, 99, 255, 0.1); }
|
||||
.control-layout { flex: 1; display: grid; grid-template-columns: 280px 1fr 280px; overflow: hidden; }
|
||||
.panel { overflow-y: auto; border-right: 1px solid var(--border-color); }
|
||||
.panel:last-child { border-right: none; border-left: 1px solid var(--border-color); }
|
||||
.panel-section { border-bottom: 1px solid var(--border-color); }
|
||||
.panel-section:last-child { border-bottom: none; }
|
||||
.panel-center { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.chat-container { flex: 1; overflow: hidden; }
|
||||
.inject-section { padding: 1rem; }
|
||||
.inject-section h3 { font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||
.inject-form { display: flex; gap: 0.4rem; }
|
||||
.form-select, .form-input { padding: 0.4rem 0.6rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-card); color: var(--text-primary); font-size: 0.8rem; }
|
||||
.form-select { min-width: 90px; }
|
||||
.form-input { flex: 1; }
|
||||
.inject-btn { padding: 0.4rem 0.8rem; border: none; border-radius: 4px; background: var(--accent-primary); color: white; font-size: 0.8rem; cursor: pointer; }
|
||||
.inject-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.quick-actions { padding: 0.75rem 1rem; display: flex; gap: 0.5rem; }
|
||||
.qa-btn { flex: 1; padding: 0.4rem 0.5rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-card); color: var(--text-secondary); font-size: 0.7rem; cursor: pointer; transition: all 0.2s; }
|
||||
.qa-btn:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="live-view">
|
||||
<div class="live-header">
|
||||
<div class="show-title">
|
||||
<h1>Hermes Live Show</h1>
|
||||
<span class="live-badge" v-if="gameStore.isRunning">● LIVE</span>
|
||||
</div>
|
||||
<div class="show-phase">{{ phaseLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div class="live-body">
|
||||
<div class="live-main">
|
||||
<ChatLog :messages="messageStore.messages" />
|
||||
</div>
|
||||
|
||||
<div class="live-sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="section-title">登场角色</div>
|
||||
<div class="live-characters">
|
||||
<div
|
||||
v-for="char in characterStore.characters"
|
||||
:key="char.id"
|
||||
class="live-char-chip"
|
||||
:class="{ dead: char.status === 'dead' }"
|
||||
>
|
||||
<div class="chip-avatar">{{ char.name[0] }}</div>
|
||||
<span>{{ char.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<ProgressBar :current-phase="gameStore.currentPhase" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section" v-if="clueStore.clues.length > 0">
|
||||
<div class="section-title">线索</div>
|
||||
<div class="live-clues">
|
||||
<div
|
||||
v-for="clue in clueStore.clues.filter(c => c.is_unlocked)"
|
||||
:key="clue.id"
|
||||
class="live-clue-item"
|
||||
>
|
||||
<span class="clue-icon">🔍</span>
|
||||
<span>{{ clue.name }}</span>
|
||||
</div>
|
||||
<div v-if="clueStore.clues.filter(c => !c.is_unlocked).length > 0" class="clue-remaining">
|
||||
还有 {{ clueStore.clues.filter(c => !c.is_unlocked).length }} 条线索待解锁
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useMessageStore } from '../stores/messageStore'
|
||||
import { useCharacterStore } from '../stores/characterStore'
|
||||
import { useClueStore } from '../stores/clueStore'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import { connectSocket, disconnectSocket } from '../services/socket'
|
||||
import ChatLog from '../components/ChatLog.vue'
|
||||
import ProgressBar from '../components/ProgressBar.vue'
|
||||
import type { Message } from '../types/message'
|
||||
import type { GamePhase } from '../types/game'
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const characterStore = useCharacterStore()
|
||||
const clueStore = useClueStore()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const PHASE_LABELS: Record<GamePhase, string> = {
|
||||
intro: '🎬 开场介绍',
|
||||
round1_speak: '💬 第一轮发言',
|
||||
round1_search: '🔍 第一轮搜证',
|
||||
round2_speak: '💬 第二轮发言',
|
||||
round2_search: '🔍 第二轮搜证',
|
||||
final_discuss: '🗣 最终讨论',
|
||||
voting: '🗳 投票环节',
|
||||
reveal: '🔎 揭晓真凶',
|
||||
}
|
||||
|
||||
const phaseLabel = computed(() => PHASE_LABELS[gameStore.currentPhase] || '准备中')
|
||||
|
||||
onMounted(async () => {
|
||||
const socket = connectSocket()
|
||||
socket.on('new_message', (msg: Message) => { messageStore.addMessage(msg) })
|
||||
socket.on('state_change', (data: { phase: GamePhase }) => { gameStore.state.current_phase = data.phase })
|
||||
socket.on('clue_unlocked', () => {
|
||||
if (gameStore.activeScriptId) {
|
||||
clueStore.fetchClues(gameStore.activeScriptId)
|
||||
}
|
||||
})
|
||||
|
||||
await gameStore.fetchState()
|
||||
if (gameStore.activeScriptId) {
|
||||
await Promise.all([
|
||||
characterStore.fetchCharacters(),
|
||||
messageStore.fetchMessages(),
|
||||
clueStore.fetchClues(gameStore.activeScriptId),
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => { disconnectSocket() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.live-view { display: flex; flex-direction: column; height: 100%; background: var(--bg-primary); }
|
||||
.live-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); }
|
||||
.show-title { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.show-title h1 { font-size: 1.2rem; font-weight: 700; background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||
.live-badge { color: var(--accent-danger); font-weight: 700; font-size: 0.8rem; animation: pulse 1.5s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.show-phase { font-size: 0.9rem; color: var(--text-secondary); font-weight: 500; }
|
||||
.live-body { flex: 1; display: flex; overflow: hidden; }
|
||||
.live-main { flex: 1; overflow: hidden; }
|
||||
.live-sidebar { width: 220px; background: var(--bg-secondary); border-left: 1px solid var(--border-color); overflow-y: auto; }
|
||||
.sidebar-section { padding: 0.75rem; border-bottom: 1px solid var(--border-color); }
|
||||
.section-title { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.live-characters { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.live-char-chip { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: 6px; background: var(--bg-card); font-size: 0.8rem; }
|
||||
.live-char-chip.dead { opacity: 0.4; }
|
||||
.chip-avatar { width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: 700; color: white; }
|
||||
.live-clues { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.live-clue-item { font-size: 0.75rem; display: flex; align-items: center; gap: 0.3rem; }
|
||||
.clue-remaining { font-size: 0.7rem; color: var(--text-muted); font-style: italic; }
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="script-import">
|
||||
<div class="import-header">
|
||||
<h2>📜 剧本导入</h2>
|
||||
<router-link to="/control" class="back-link">← 返回控制台</router-link>
|
||||
</div>
|
||||
|
||||
<div class="import-body">
|
||||
<div class="import-form">
|
||||
<div class="form-group">
|
||||
<label>剧本标题</label>
|
||||
<input v-model="title" type="text" placeholder="输入剧本标题" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>文件类型</label>
|
||||
<select v-model="fileType" class="form-input">
|
||||
<option value="natural_language">自然语言(自动解析)</option>
|
||||
<option value="json">JSON 格式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>剧本内容</label>
|
||||
<textarea
|
||||
v-model="content"
|
||||
placeholder="在此粘贴完整剧本内容..."
|
||||
class="form-textarea"
|
||||
rows="15"
|
||||
></textarea>
|
||||
</div>
|
||||
<button class="import-btn" :disabled="!title || !content || uploading" @click="doUpload">
|
||||
{{ uploading ? '解析中...' : '📤 上传解析' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="import-preview" v-if="preview">
|
||||
<h3>解析预览</h3>
|
||||
<div class="preview-section">
|
||||
<h4>📖 {{ preview.title }}</h4>
|
||||
<p class="preview-bg">{{ preview.background }}</p>
|
||||
</div>
|
||||
<div class="preview-section">
|
||||
<h4>角色 ({{ preview.characters.length }})</h4>
|
||||
<ul>
|
||||
<li v-for="(char, idx) in preview.characters" :key="idx">
|
||||
<strong>{{ char.name }}</strong>
|
||||
<span v-if="char.personality"> — {{ char.personality }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="preview-section">
|
||||
<h4>线索 ({{ preview.clues.length }})</h4>
|
||||
<ul>
|
||||
<li v-for="(clue, idx) in preview.clues" :key="idx">
|
||||
{{ clue.content }}
|
||||
<span class="clue-meta">({{ clue.owner }} · {{ clue.phase }})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="confirm-btn" :disabled="importing" @click="doImport">
|
||||
{{ importing ? '导入中...' : '✅ 确认导入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-scripts">
|
||||
<h3>已导入剧本</h3>
|
||||
<div class="script-list" v-if="scripts.length > 0">
|
||||
<div
|
||||
v-for="script in scripts"
|
||||
:key="script.id"
|
||||
class="script-card"
|
||||
:class="{ active: gameStore.activeScriptId === script.id }"
|
||||
>
|
||||
<div class="script-info">
|
||||
<span class="script-title">{{ script.title }}</span>
|
||||
<span class="script-meta">{{ script.character_count }} 角色 · {{ formatDate(script.created_at) }}</span>
|
||||
</div>
|
||||
<div class="script-actions">
|
||||
<button class="action-link" @click="selectScript(script.id)">
|
||||
{{ gameStore.activeScriptId === script.id ? '已选中' : '选择' }}
|
||||
</button>
|
||||
<button class="action-link danger" @click="deleteScript(script.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="script-empty" v-else>暂未导入剧本</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import type { Script, ScriptParsePreview, ScriptImportRequest } from '../types/script'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const fileType = ref('natural_language')
|
||||
const uploading = ref(false)
|
||||
const importing = ref(false)
|
||||
const preview = ref<ScriptParsePreview | null>(null)
|
||||
const scripts = ref<Script[]>([])
|
||||
|
||||
async function doUpload() {
|
||||
if (!title.value || !content.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
preview.value = await api.post<ScriptParsePreview>('/scripts/upload', {
|
||||
title: title.value,
|
||||
content: content.value,
|
||||
file_type: fileType.value,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Upload failed:', e)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!preview.value) return
|
||||
importing.value = true
|
||||
try {
|
||||
const req: ScriptImportRequest = {
|
||||
title: preview.value.title,
|
||||
background: preview.value.background,
|
||||
characters: preview.value.characters,
|
||||
clues: preview.value.clues,
|
||||
phases: preview.value.phases,
|
||||
}
|
||||
await api.post<Script>('/scripts/import', req)
|
||||
preview.value = null
|
||||
resetForm()
|
||||
await loadScripts()
|
||||
} catch (e) {
|
||||
console.error('Import failed:', e)
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
scripts.value = await api.get<Script[]>('/scripts')
|
||||
}
|
||||
|
||||
async function selectScript(scriptId: string) {
|
||||
await gameStore.fetchState()
|
||||
await gameStore.startGame(scriptId)
|
||||
}
|
||||
|
||||
async function deleteScript(scriptId: string) {
|
||||
await api.delete(`/scripts/${scriptId}`)
|
||||
await loadScripts()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
title.value = ''
|
||||
content.value = ''
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => { loadScripts() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.script-import { height: 100%; overflow-y: auto; padding: 2rem; max-width: 1000px; margin: 0 auto; }
|
||||
.import-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
||||
.import-header h2 { font-size: 1.4rem; }
|
||||
.back-link { color: var(--accent-primary); text-decoration: none; font-size: 0.9rem; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.import-body { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 2rem; }
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-group label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.4rem; color: var(--text-secondary); }
|
||||
.form-input, .form-textarea { width: 100%; padding: 0.6rem 0.8rem; border: 1px solid var(--border-color); border-radius: var(--border-radius); background: var(--bg-card); color: var(--text-primary); font-size: 0.9rem; font-family: var(--font-sans); transition: border-color 0.2s; }
|
||||
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--accent-primary); }
|
||||
.form-textarea { resize: vertical; font-family: var(--font-mono); font-size: 0.8rem; line-height: 1.6; }
|
||||
.import-btn, .confirm-btn { padding: 0.7rem 1.5rem; border: none; border-radius: var(--border-radius); font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
|
||||
.import-btn { background: var(--accent-primary); color: white; }
|
||||
.import-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.confirm-btn { background: var(--accent-success); color: white; }
|
||||
.confirm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.import-preview { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 1rem; }
|
||||
.import-preview h3 { font-size: 0.95rem; margin-bottom: 1rem; }
|
||||
.preview-section { margin-bottom: 1rem; }
|
||||
.preview-section h4 { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.4rem; }
|
||||
.preview-bg { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.5; }
|
||||
.preview-section ul { list-style: none; padding: 0; }
|
||||
.preview-section li { font-size: 0.8rem; padding: 0.15rem 0; }
|
||||
.clue-meta { color: var(--text-muted); font-size: 0.7rem; }
|
||||
.import-scripts { margin-top: 2rem; }
|
||||
.import-scripts h3 { font-size: 1rem; margin-bottom: 1rem; }
|
||||
.script-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.script-card { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius); }
|
||||
.script-card.active { border-color: var(--accent-primary); }
|
||||
.script-title { font-weight: 500; }
|
||||
.script-meta { font-size: 0.75rem; color: var(--text-secondary); margin-left: 0.75rem; }
|
||||
.script-actions { display: flex; gap: 0.5rem; }
|
||||
.action-link { background: none; border: 1px solid var(--border-color); padding: 0.25rem 0.6rem; border-radius: 4px; font-size: 0.75rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
|
||||
.action-link:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
|
||||
.action-link.danger:hover { border-color: var(--accent-danger); color: var(--accent-danger); }
|
||||
.script-empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/env.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user