文字游戏
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

776 lines
18 KiB

2 months ago
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import { useAuthStore } from '@/stores/auth'
2 months ago
import { ElProgress } from 'element-plus'
2 months ago
import Particles from '@/components/Particles/Particles.vue'
import GlareHover from '@/components/GlareHover/GlareHover.vue'
const router = useRouter()
const characterStore = useCharacterStore()
const authStore = useAuthStore()
const showCreateDialog = ref(false)
const newCharacterName = ref('')
const newCharacterProfessionId = ref<number | null>(null)
const showProfessionSelection = ref(false)
const errorMsg = ref('')
onMounted(async () => {
await characterStore.fetchCharacters()
await characterStore.fetchProfessions()
})
const openCreateDialog = () => {
newCharacterName.value = ''
newCharacterProfessionId.value = null
showProfessionSelection.value = false
errorMsg.value = ''
showCreateDialog.value = true
}
const handleShowProfessionSelection = () => {
if (newCharacterName.value && newCharacterName.value.trim().length >= 2) {
showProfessionSelection.value = true
}
}
const handleSelectProfession = (professionId: number) => {
newCharacterProfessionId.value = professionId
}
const getRateBarWidth = (rate: number) => {
return Math.min(rate * 50, 100)
}
const getRateBarColor = (rate: number) => {
return rate > 1.0 ? 'red' : 'green'
}
const handleCreateCharacter = async () => {
errorMsg.value = ''
if (!newCharacterName.value || newCharacterName.value.trim().length < 2) {
errorMsg.value = '角色名称至少2个字符'
return
}
if (!newCharacterProfessionId.value) {
errorMsg.value = '请选择职业'
return
}
const success = await characterStore.createCharacter(newCharacterName.value.trim(), newCharacterProfessionId.value)
if (success) {
showCreateDialog.value = false
newCharacterName.value = ''
newCharacterProfessionId.value = null
showProfessionSelection.value = false
} else {
errorMsg.value = '创建失败,可能名称已存在或已达角色数量上限(3个)'
}
}
const handleSelectCharacter = async (characterId: number) => {
const success = await characterStore.selectCharacter(characterId)
if (success) {
router.push('/game')
}
}
const handleDeleteCharacter = async (characterId: number, event: Event) => {
event.stopPropagation()
if (confirm('确定要删除这个角色吗?此操作不可恢复!')) {
await characterStore.deleteCharacter(characterId)
}
}
const handleLogout = () => {
authStore.logout()
characterStore.clearCurrentCharacter()
window.location.href = '/login'
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '首次登录'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
}
2 months ago
const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
if (!nextLevelMinExp) return 0
return Math.min(100, (currentExp / nextLevelMinExp) * 100)
}
2 months ago
</script>
<template>
<div class="character-page">
<Particles
:particle-count="100"
:particle-colors="['#ffffff', '#cccccc']"
class="particles-bg"
/>
<div class="character-container">
<div class="page-header">
<h1 class="title">选择角色</h1>
<p class="subtitle">修仙者: {{ authStore.username }}</p>
</div>
<div class="character-list">
<div
v-for="char in characterStore.characters"
:key="char.id"
class="character-card"
@click="handleSelectCharacter(char.id)"
>
<div class="character-info">
<div class="character-name">{{ char.name }}</div>
<div class="character-level">{{ char.levelName }}</div>
</div>
<div class="character-stats">
<div class="stat-item">
<span class="stat-label">生命</span>
2 months ago
<span class="stat-value">{{ Math.floor(char.maxHP) }}</span>
2 months ago
</div>
<div class="stat-item">
<span class="stat-label">攻击</span>
2 months ago
<span class="stat-value">{{ Math.floor(char.attack) }}</span>
2 months ago
</div>
<div class="stat-item">
2 months ago
<span class="stat-label">防御</span>
<span class="stat-value">{{ Math.floor(char.defend) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">暴击</span>
<span class="stat-value">{{ Math.floor(char.criticalRate * 100) }}%</span>
2 months ago
</div>
2 months ago
</div>
<div class="character-second-stats">
2 months ago
<div v-if="char.professionName" class="stat-item">
<span class="stat-label">职业</span>
<span class="stat-value">{{ char.professionName }}</span>
</div>
2 months ago
<div class="stat-item">
<span class="stat-label">灵石</span>
<span class="stat-value">{{ char.money }}</span>
</div>
</div>
<div class="character-extended-stats">
<div class="stat-item exp-item">
<span class="stat-label">经验</span>
<el-progress
:percentage="getExpProgress(char.currentExp, char.nextLevelMinExp)"
color="#ff8844"
:show-text="false"
:stroke-width="4"
/>
<span class="stat-value-small">{{ Math.floor(char.currentExp) }}/{{ char.nextLevelMinExp }}</span>
</div>
<div class="stat-item">
<span class="stat-label">修炼</span>
<span class="stat-value">{{ char.trainingExpRate }}x</span>
</div>
<div class="stat-item">
<span class="stat-label">突破</span>
<span class="stat-value">{{ char.breakthroughRate }}%</span>
</div>
2 months ago
</div>
<div class="character-meta">
<span class="last-login">上次登录: {{ formatDate(char.lastLogin) }}</span>
</div>
<button class="delete-btn" @click="handleDeleteCharacter(char.id, $event)">
删除
</button>
</div>
<!-- 创建角色卡片 -->
<div
v-if="characterStore.characters.length < 3"
class="character-card create-card"
@click="openCreateDialog"
>
<div class="create-icon">+</div>
<div class="create-text">创建角色</div>
</div>
</div>
<div class="page-footer">
<GlareHover
width="120px"
height="36px"
background="transparent"
border-radius="18px"
border-color="rgba(255,255,255,0.1)"
glare-color="#ffffff"
:glare-opacity="0.1"
@click="handleLogout"
>
<span class="logout-text">退出登录</span>
</GlareHover>
</div>
</div>
<!-- 创建角色对话框 -->
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
<div class="dialog" @click.stop>
<h2 class="dialog-title">创建角色</h2>
<input
v-model="newCharacterName"
type="text"
class="dialog-input"
placeholder="输入角色名称"
maxlength="20"
@input="showProfessionSelection = false; newCharacterProfessionId = null"
@keyup.enter="handleShowProfessionSelection"
/>
<!-- 选择职业按钮 -->
<button
v-if="newCharacterName.trim().length >= 2 && !showProfessionSelection"
class="select-profession-btn"
@click="handleShowProfessionSelection"
>
选择职业
</button>
<!-- 职业卡片列表 -->
<div v-if="showProfessionSelection" class="profession-cards">
<div
v-for="profession in characterStore.professions"
:key="profession.id"
class="profession-card"
:class="{ selected: newCharacterProfessionId === profession.id }"
@click="handleSelectProfession(profession.id)"
>
<div class="profession-header">
<div class="profession-name">{{ profession.name }}</div>
<div class="profession-desc">{{ profession.description }}</div>
</div>
<!-- 系数条形图 -->
<div class="rate-bars">
<div class="rate-item">
<span class="rate-label">攻击</span>
<div class="bar-track">
<div
class="bar-fill"
:class="getRateBarColor(profession.attackRate)"
:style="{ width: getRateBarWidth(profession.attackRate) + '%' }"
></div>
<div class="axis-line"></div>
</div>
<span class="rate-value" :class="getRateBarColor(profession.attackRate)">{{ profession.attackRate }}</span>
</div>
<div class="rate-item">
<span class="rate-label">防御</span>
<div class="bar-track">
<div
class="bar-fill"
:class="getRateBarColor(profession.defendRate)"
:style="{ width: getRateBarWidth(profession.defendRate) + '%' }"
></div>
<div class="axis-line"></div>
</div>
<span class="rate-value" :class="getRateBarColor(profession.defendRate)">{{ profession.defendRate }}</span>
</div>
<div class="rate-item">
<span class="rate-label">生命</span>
<div class="bar-track">
<div
class="bar-fill"
:class="getRateBarColor(profession.healthRate)"
:style="{ width: getRateBarWidth(profession.healthRate) + '%' }"
></div>
<div class="axis-line"></div>
</div>
<span class="rate-value" :class="getRateBarColor(profession.healthRate)">{{ profession.healthRate }}</span>
</div>
<div class="rate-item">
<span class="rate-label">暴击</span>
<div class="bar-track">
<div
class="bar-fill"
:class="getRateBarColor(profession.criticalRate)"
:style="{ width: getRateBarWidth(profession.criticalRate) + '%' }"
></div>
<div class="axis-line"></div>
</div>
<span class="rate-value" :class="getRateBarColor(profession.criticalRate)">{{ profession.criticalRate }}</span>
</div>
</div>
<div v-if="newCharacterProfessionId === profession.id" class="selected-check">
已选择
</div>
</div>
</div>
<div v-if="errorMsg" class="error-message">{{ errorMsg }}</div>
<div class="dialog-actions">
<button class="cancel-btn" @click="showCreateDialog = false">取消</button>
<button
class="confirm-btn"
:disabled="!newCharacterProfessionId"
@click="handleCreateCharacter"
>
创建
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.character-page {
min-height: 100vh;
background: #000000;
position: relative;
overflow: hidden;
}
.particles-bg {
position: fixed !important;
top: 0;
left: 0;
width: 100% !important;
height: 100% !important;
}
.character-container {
position: relative;
z-index: 10;
max-width: 480px;
margin: 0 auto;
padding: 40px 20px;
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.title {
font-size: 1.5rem;
font-weight: 300;
color: #ffffff;
letter-spacing: 0.15em;
margin-bottom: 8px;
}
.subtitle {
color: #999999;
font-size: 0.875rem;
}
.character-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.character-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 20px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.character-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
}
.character-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-right: 60px;
}
.character-name {
font-size: 1.1rem;
color: #ffffff;
font-weight: 500;
}
.character-level {
color: #bbbbbb;
font-size: 0.875rem;
}
.character-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
2 months ago
margin-bottom: 8px;
}
.character-second-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 8px;
2 months ago
}
.stat-item {
text-align: center;
}
.stat-label {
display: block;
color: #888888;
font-size: 0.7rem;
margin-bottom: 2px;
}
.stat-value {
color: #ffffff;
font-size: 0.85rem;
}
.character-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.last-login {
color: #666666;
font-size: 0.75rem;
}
2 months ago
.character-extended-stats {
display: flex;
justify-content: space-between;
gap: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
margin-top: 8px;
}
.character-extended-stats .stat-item {
flex: 1;
text-align: center;
}
.exp-item {
flex: 2 !important;
}
.stat-value-small {
display: block;
color: #666666;
font-size: 0.65rem;
margin-top: 2px;
}
2 months ago
.delete-btn {
position: absolute;
top: 12px;
right: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #aaaaaa;
font-size: 0.7rem;
padding: 4px 10px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.delete-btn:hover {
background: rgba(255, 100, 100, 0.2);
border-color: rgba(255, 100, 100, 0.3);
color: #ff6666;
}
.create-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
border-style: dashed;
}
.create-card:hover {
border-style: solid;
}
.create-icon {
font-size: 2rem;
color: #666666;
margin-bottom: 8px;
}
.create-text {
color: #999999;
font-size: 0.875rem;
}
.page-footer {
margin-top: 32px;
display: flex;
justify-content: center;
}
.logout-text {
color: #666666;
font-size: 0.8rem;
}
/* Dialog */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 24px;
width: 90%;
max-width: 320px;
}
.dialog-title {
color: #ffffff;
font-size: 1.1rem;
font-weight: 500;
text-align: center;
margin-bottom: 20px;
}
.dialog-input {
width: 100%;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #ffffff;
font-size: 0.95rem;
outline: none;
margin-bottom: 12px;
}
.dialog-input::placeholder {
color: #444444;
}
.dialog-input:focus {
border-color: rgba(255, 255, 255, 0.3);
}
.select-profession-btn {
width: 100%;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 10px;
color: #ffffff;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 12px;
}
.select-profession-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.profession-cards {
max-height: 300px;
overflow-y: auto;
margin-bottom: 12px;
}
.profession-cards::-webkit-scrollbar {
display: none;
}
.profession-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 12px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.profession-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.2);
}
.profession-card.selected {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(100, 200, 100, 0.5);
}
.profession-header {
margin-bottom: 10px;
}
.profession-name {
font-size: 1rem;
color: #ffffff;
font-weight: 500;
margin-bottom: 4px;
}
.profession-desc {
font-size: 0.75rem;
color: #888888;
}
.rate-bars {
display: flex;
flex-direction: column;
gap: 6px;
}
.rate-item {
display: flex;
align-items: center;
gap: 8px;
}
.rate-label {
width: 32px;
font-size: 0.7rem;
color: #888888;
}
.bar-track {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
position: relative;
}
.bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.bar-fill.green {
background: linear-gradient(90deg, #4ade80, #22c55e);
}
.bar-fill.red {
background: linear-gradient(90deg, #f87171, #ef4444);
}
.axis-line {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.3);
border-left: 1px dashed rgba(255, 255, 255, 0.5);
}
.rate-value {
width: 36px;
font-size: 0.75rem;
text-align: right;
}
.rate-value.green {
color: #4ade80;
}
.rate-value.red {
color: #f87171;
}
.selected-check {
position: absolute;
top: 8px;
right: 8px;
color: #4ade80;
font-size: 0.75rem;
}
.confirm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
color: #888888;
font-size: 0.8rem;
text-align: center;
margin-bottom: 12px;
}
.dialog-actions {
display: flex;
gap: 12px;
}
.cancel-btn,
.confirm-btn {
flex: 1;
padding: 10px;
border-radius: 10px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #888888;
}
.cancel-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.confirm-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #ffffff;
}
.confirm-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
</style>