🎨 重构用户端前端为vue开发,完善基础类和角色相关接口

This commit is contained in:
2026-02-10 21:55:45 +08:00
parent db934ebed7
commit 56e821b222
92 changed files with 18377 additions and 21 deletions

View File

@@ -0,0 +1,3 @@
# 开发环境配置
VITE_API_BASE_URL=http://localhost:8888
VITE_WS_URL=ws://localhost:8888

View File

@@ -0,0 +1,3 @@
# 生产环境配置
VITE_API_BASE_URL=https://your-domain.com
VITE_WS_URL=wss://your-domain.com

24
web-app-vue/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
web-app-vue/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

35
web-app-vue/README.md Normal file
View File

@@ -0,0 +1,35 @@
# 云酒馆前台应用 (Vue 3)
## 技术栈
- Vue 3 + TypeScript
- Vite 5
- Element Plus
- Vue Router 4
- Pinia
- Axios
## 开发
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
# 预览生产构建
npm run preview
```
## 访问地址
- 开发环境: http://localhost:3000
- API 代理: http://localhost:8888
## 项目结构
详见 `/docs/Vue重构方案.md`

13
web-app-vue/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<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>web-app-vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2998
web-app-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
web-app-vue/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "web-app-vue",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.2.1",
"axios": "^1.13.5",
"element-plus": "^2.13.2",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"sass": "^1.97.3",
"typescript": "~5.9.3",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

22
web-app-vue/src/App.vue Normal file
View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
// 初始化用户信息
onMounted(() => {
authStore.initUserInfo()
})
</script>
<template>
<router-view />
</template>
<style>
#app {
width: 100%;
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,85 @@
import request from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type {
LoginRequest,
LoginResponse,
RegisterRequest,
User,
ChangePasswordRequest,
UpdateProfileRequest,
} from '@/types/user'
/**
* 用户登录
*/
export function login(data: LoginRequest): Promise<ApiResponse<LoginResponse>> {
return request({
url: '/app/auth/login',
method: 'post',
data,
})
}
/**
* 用户注册
*/
export function register(data: RegisterRequest): Promise<ApiResponse<void>> {
return request({
url: '/app/auth/register',
method: 'post',
data,
})
}
/**
* 获取用户信息
*/
export function getUserInfo(): Promise<ApiResponse<User>> {
return request({
url: '/app/auth/userinfo',
method: 'get',
})
}
/**
* 用户登出
*/
export function logout(): Promise<ApiResponse<void>> {
return request({
url: '/app/auth/logout',
method: 'post',
})
}
/**
* 刷新 Token
*/
export function refreshToken(refreshToken: string): Promise<ApiResponse<LoginResponse>> {
return request({
url: '/app/auth/refresh',
method: 'post',
data: { refreshToken },
})
}
/**
* 修改密码
*/
export function changePassword(data: ChangePasswordRequest): Promise<ApiResponse<void>> {
return request({
url: '/app/user/change-password',
method: 'post',
data,
})
}
/**
* 更新用户信息
*/
export function updateProfile(data: UpdateProfileRequest): Promise<ApiResponse<void>> {
return request({
url: '/app/user/profile',
method: 'put',
data,
})
}

View File

@@ -0,0 +1,128 @@
import request from '@/utils/request'
import type {
Character,
CharacterListResponse,
CharacterListParams,
CreateCharacterRequest,
UpdateCharacterRequest,
CharacterExportData
} from '@/types/character'
/**
* 获取公开角色卡列表(无需鉴权)
*/
export function getPublicCharacterList(params: CharacterListParams) {
return request.get<CharacterListResponse>('/app/character/public', { params })
}
/**
* 获取我的角色卡列表
*/
export function getMyCharacterList(params: CharacterListParams) {
return request.get<CharacterListResponse>('/app/character/my', { params })
}
/**
* 获取角色卡详情
*/
export function getCharacterDetail(id: number) {
return request.get<Character>(`/app/character/${id}`)
}
/**
* 创建角色卡
*/
export function createCharacter(data: CreateCharacterRequest) {
return request.post<Character>('/app/character', data)
}
/**
* 更新角色卡
*/
export function updateCharacter(data: UpdateCharacterRequest) {
return request.put<Character>('/app/character', data)
}
/**
* 删除角色卡
*/
export function deleteCharacter(id: number) {
return request.delete(`/app/character/${id}`)
}
/**
* 切换收藏状态
*/
export function toggleFavorite(characterId: number) {
return request.post<{ isFavorited: boolean }>('/app/character/favorite', { characterId })
}
/**
* 点赞角色卡
*/
export function likeCharacter(characterId: number) {
return request.post('/app/character/like', { characterId })
}
/**
* 导出角色卡为 JSON
*/
export function exportCharacter(id: number) {
return request.get<CharacterExportData>(`/app/character/${id}/export`)
}
/**
* 导出角色卡为 PNG
*/
export function exportCharacterAsPNG(id: number) {
return request.get(`/app/character/${id}/export/png`, {
responseType: 'blob'
})
}
/**
* 下载角色卡为 JSON 文件
*/
export async function downloadCharacterJSON(id: number, filename?: string) {
const data = await exportCharacter(id)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || `character_${id}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
* 下载角色卡为 PNG 文件
*/
export async function downloadCharacterPNG(id: number, filename?: string) {
const { data } = await exportCharacterAsPNG(id)
const blob = new Blob([data], { type: 'image/png' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || `character_${id}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
* 导入角色卡
*/
export function importCharacter(file: File, isPublic: boolean = false) {
const formData = new FormData()
formData.append('file', file)
formData.append('isPublic', isPublic.toString())
return request.post<Character>('/app/character/import', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}

View File

@@ -0,0 +1,50 @@
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
font-family: 'Segoe UI', 'Noto Sans', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
min-height: 100vh;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--el-fill-color-light);
}
::-webkit-scrollbar-thumb {
background: var(--el-border-color-darker);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color-dark);
}
/* 选中文本样式 */
::selection {
background: var(--el-color-primary);
color: #fff;
}
/* 平滑滚动 */
html {
scroll-behavior: smooth;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

90
web-app-vue/src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,90 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

46
web-app-vue/src/components.d.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']
ElUpload: typeof import('element-plus/es')['ElUpload']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<div class="auth-layout">
<!-- 背景装饰 -->
<div class="auth-background">
<div class="gradient-orb orb-1"></div>
<div class="gradient-orb orb-2"></div>
<div class="gradient-orb orb-3"></div>
</div>
<!-- 主内容 -->
<div class="auth-content">
<router-view />
</div>
<!-- 页脚 -->
<div class="auth-footer">
<p>© 2026 云酒馆 | Powered by AI</p>
</div>
</div>
</template>
<script setup lang="ts">
// 认证页面布局
</script>
<style scoped lang="scss">
.auth-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
overflow: hidden;
}
.auth-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.gradient-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
animation: float 20s infinite ease-in-out;
}
.orb-1 {
width: 500px;
height: 500px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
top: -150px;
left: -150px;
animation-delay: 0s;
}
.orb-2 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, #ec4899, #f43f5e);
bottom: -100px;
right: -100px;
animation-delay: 7s;
}
.orb-3 {
width: 300px;
height: 300px;
background: linear-gradient(135deg, #14b8a6, #06b6d4);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: 14s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -30px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
}
.auth-content {
position: relative;
z-index: 1;
width: 100%;
max-width: 480px;
}
.auth-footer {
position: fixed;
bottom: 20px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 13px;
z-index: 1;
}
@media (max-width: 640px) {
.gradient-orb {
filter: blur(60px);
}
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<el-container class="default-layout">
<!-- 顶部导航栏 -->
<el-header class="layout-header">
<div class="header-left">
<h1 class="logo" @click="router.push('/')">云酒馆</h1>
<!-- 主导航菜单 -->
<el-menu
:default-active="activeMenu"
mode="horizontal"
:ellipsis="false"
class="header-menu"
@select="handleMenuSelect"
>
<el-menu-item index="/">
<el-icon><Grid /></el-icon>
<span>角色广场</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/my-characters">
<el-icon><Files /></el-icon>
<span>我的角色卡</span>
</el-menu-item>
</el-menu>
</div>
<div class="header-right">
<!-- 未登录状态 -->
<div v-if="!authStore.isLoggedIn" class="auth-buttons">
<el-button size="small" @click="router.push('/auth/login')">
登录
</el-button>
<el-button type="primary" size="small" @click="router.push('/auth/register')">
注册
</el-button>
</div>
<!-- 已登录状态 -->
<el-dropdown v-else @command="handleCommand">
<div class="user-info">
<el-avatar :src="authStore.userAvatar" size="small">
{{ authStore.userName?.charAt(0) }}
</el-avatar>
<span class="username">{{ authStore.userName }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="settings">设置</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主内容区 -->
<el-main class="layout-main">
<router-view />
</el-main>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Grid, Files } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 初始化用户信息
authStore.initUserInfo()
// 当前激活的菜单
const activeMenu = computed(() => {
if (route.path.startsWith('/my-characters')) return '/my-characters'
return '/'
})
// 菜单选择处理
function handleMenuSelect(index: string) {
router.push(index)
}
// 下拉菜单命令处理
function handleCommand(command: string) {
switch (command) {
case 'profile':
router.push('/user/profile')
break
case 'settings':
router.push('/settings')
break
case 'logout':
authStore.handleLogout().then(() => {
router.push('/auth/login')
})
break
}
}
</script>
<style scoped lang="scss">
.default-layout {
min-height: 100vh;
}
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color);
height: 60px;
.header-left {
display: flex;
align-items: center;
gap: 32px;
.logo {
font-size: 20px;
font-weight: bold;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
cursor: pointer;
user-select: none;
}
.header-menu {
border: none;
background: transparent;
}
}
.header-right {
.auth-buttons {
display: flex;
gap: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.3s;
&:hover {
background: var(--el-fill-color-light);
}
.username {
font-size: 14px;
}
}
}
}
.layout-main {
padding: 20px;
background: var(--el-bg-color-page);
}
</style>

19
web-app-vue/src/main.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import router from './router'
import App from './App.vue'
import './assets/styles/index.scss'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
size: 'default',
})
app.mount('#app')

View File

@@ -0,0 +1,95 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
const routes: RouteRecordRaw[] = [
{
path: '/auth',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: '',
redirect: '/auth/login',
},
{
path: 'login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { title: '登录' },
},
{
path: 'register',
name: 'Register',
component: () => import('@/views/auth/Register.vue'),
meta: { title: '注册' },
},
],
},
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/home/Index.vue'),
meta: { title: '角色广场', requiresAuth: false },
},
{
path: 'character/:id',
name: 'CharacterDetail',
component: () => import('@/views/character/Detail.vue'),
meta: { title: '角色详情', requiresAuth: false },
},
{
path: 'my-characters',
name: 'MyCharacters',
component: () => import('@/views/character/MyCharacters.vue'),
meta: { title: '我的角色卡', requiresAuth: true },
},
{
path: 'character/create',
name: 'CreateCharacter',
component: () => import('@/views/character/Edit.vue'),
meta: { title: '创建角色卡', requiresAuth: true },
},
{
path: 'character/:id/edit',
name: 'EditCharacter',
component: () => import('@/views/character/Edit.vue'),
meta: { title: '编辑角色卡', requiresAuth: true },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('st_access_token')
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 云酒馆` : '云酒馆'
// 需要认证的路由
if (to.meta.requiresAuth !== false && !to.path.startsWith('/auth')) {
if (!token) {
ElMessage.warning('请先登录')
next('/auth/login')
return
}
}
// 已登录用户访问认证页面,重定向到首页
if (token && to.path.startsWith('/auth')) {
next('/')
return
}
next()
})
export default router

View File

@@ -0,0 +1,141 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import * as authApi from '@/api/auth'
import type { User, LoginRequest, RegisterRequest } from '@/types/user'
export const useAuthStore = defineStore('auth', () => {
// 状态
const token = ref<string>(localStorage.getItem('st_access_token') || '')
const refreshToken = ref<string>(localStorage.getItem('st_refresh_token') || '')
const user = ref<User | null>(null)
const loading = ref(false)
// 计算属性
const isLoggedIn = computed(() => !!token.value)
const userAvatar = computed(() => user.value?.avatar || '')
const userName = computed(() => user.value?.nickName || user.value?.username || '')
// 初始化用户信息
function initUserInfo() {
const userInfoStr = localStorage.getItem('st_user_info')
if (userInfoStr) {
try {
user.value = JSON.parse(userInfoStr)
} catch (error) {
console.error('解析用户信息失败:', error)
}
}
}
// 登录
async function handleLogin(data: LoginRequest): Promise<boolean> {
try {
loading.value = true
const res = await authApi.login(data)
// 保存 token 和用户信息
token.value = res.data.token
refreshToken.value = res.data.refreshToken
user.value = res.data.user
localStorage.setItem('st_access_token', res.data.token)
localStorage.setItem('st_refresh_token', res.data.refreshToken)
localStorage.setItem('st_user_info', JSON.stringify(res.data.user))
ElMessage.success('登录成功')
return true
} catch (error) {
console.error('登录失败:', error)
return false
} finally {
loading.value = false
}
}
// 注册
async function handleRegister(data: RegisterRequest): Promise<boolean> {
try {
loading.value = true
await authApi.register(data)
ElMessage.success('注册成功,请登录')
return true
} catch (error) {
console.error('注册失败:', error)
return false
} finally {
loading.value = false
}
}
// 登出
async function handleLogout() {
try {
await authApi.logout()
} catch (error) {
console.error('登出请求失败:', error)
} finally {
// 清除本地数据
token.value = ''
refreshToken.value = ''
user.value = null
localStorage.removeItem('st_access_token')
localStorage.removeItem('st_refresh_token')
localStorage.removeItem('st_user_info')
ElMessage.success('已退出登录')
}
}
// 获取用户信息
async function fetchUserInfo() {
try {
const res = await authApi.getUserInfo()
user.value = res.data
localStorage.setItem('st_user_info', JSON.stringify(res.data))
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
// 刷新 Token
async function handleRefreshToken(): Promise<boolean> {
try {
if (!refreshToken.value) {
return false
}
const res = await authApi.refreshToken(refreshToken.value)
token.value = res.data.token
refreshToken.value = res.data.refreshToken
user.value = res.data.user
localStorage.setItem('st_access_token', res.data.token)
localStorage.setItem('st_refresh_token', res.data.refreshToken)
localStorage.setItem('st_user_info', JSON.stringify(res.data.user))
return true
} catch (error) {
console.error('刷新 Token 失败:', error)
await handleLogout()
return false
}
}
return {
token,
refreshToken,
user,
loading,
isLoggedIn,
userAvatar,
userName,
initUserInfo,
handleLogin,
handleRegister,
handleLogout,
fetchUserInfo,
handleRefreshToken,
}
})

View File

@@ -0,0 +1,334 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
Character,
CharacterListParams,
CreateCharacterRequest,
UpdateCharacterRequest
} from '@/types/character'
import * as characterApi from '@/api/character'
import { ElMessage } from 'element-plus'
export const useCharacterStore = defineStore('character', () => {
// 状态
const publicCharacters = ref<Character[]>([])
const myCharacters = ref<Character[]>([])
const currentCharacter = ref<Character | null>(null)
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
// 计算属性
const hasMore = computed(() => publicCharacters.value.length < total.value)
/**
* 获取公开角色卡列表
*/
async function fetchPublicCharacters(params?: CharacterListParams, append = false) {
loading.value = true
try {
const { data } = await characterApi.getPublicCharacterList({
page: page.value,
pageSize: pageSize.value,
...params
})
if (append) {
publicCharacters.value.push(...data.list)
} else {
publicCharacters.value = data.list
}
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
return data
} catch (error) {
ElMessage.error('获取角色卡列表失败')
throw error
} finally {
loading.value = false
}
}
/**
* 加载更多公开角色卡
*/
async function loadMore(params?: CharacterListParams) {
if (!hasMore.value || loading.value) return
page.value += 1
await fetchPublicCharacters(params, true)
}
/**
* 重置分页
*/
function resetPagination() {
page.value = 1
total.value = 0
publicCharacters.value = []
}
/**
* 获取我的角色卡列表
*/
async function fetchMyCharacters(params?: CharacterListParams) {
loading.value = true
try {
const { data } = await characterApi.getMyCharacterList({
page: 1,
pageSize: 100,
...params
})
myCharacters.value = data.list
return data
} catch (error) {
ElMessage.error('获取我的角色卡失败')
throw error
} finally {
loading.value = false
}
}
/**
* 获取角色卡详情
*/
async function fetchCharacterDetail(id: number) {
loading.value = true
try {
const { data } = await characterApi.getCharacterDetail(id)
currentCharacter.value = data
return data
} catch (error) {
ElMessage.error('获取角色卡详情失败')
throw error
} finally {
loading.value = false
}
}
/**
* 创建角色卡
*/
async function createCharacter(data: CreateCharacterRequest) {
loading.value = true
try {
const response = await characterApi.createCharacter(data)
ElMessage.success('创建成功')
// 添加到我的角色卡列表
myCharacters.value.unshift(response.data)
return response.data
} catch (error) {
ElMessage.error('创建失败')
throw error
} finally {
loading.value = false
}
}
/**
* 更新角色卡
*/
async function updateCharacter(data: UpdateCharacterRequest) {
loading.value = true
try {
const response = await characterApi.updateCharacter(data)
ElMessage.success('更新成功')
// 更新列表中的角色卡
const index = myCharacters.value.findIndex(c => c.id === data.id)
if (index !== -1) {
myCharacters.value[index] = response.data
}
// 更新当前角色卡
if (currentCharacter.value?.id === data.id) {
currentCharacter.value = response.data
}
return response.data
} catch (error) {
ElMessage.error('更新失败')
throw error
} finally {
loading.value = false
}
}
/**
* 删除角色卡
*/
async function deleteCharacter(id: number) {
loading.value = true
try {
await characterApi.deleteCharacter(id)
ElMessage.success('删除成功')
// 从列表中移除
myCharacters.value = myCharacters.value.filter(c => c.id !== id)
// 清除当前角色卡
if (currentCharacter.value?.id === id) {
currentCharacter.value = null
}
} catch (error) {
ElMessage.error('删除失败')
throw error
} finally {
loading.value = false
}
}
/**
* 切换收藏状态
*/
async function toggleFavorite(characterId: number) {
try {
const { data } = await characterApi.toggleFavorite(characterId)
// 更新列表中的收藏状态
const updateFavoriteStatus = (list: Character[]) => {
const character = list.find(c => c.id === characterId)
if (character) {
character.isFavorited = data.isFavorited
character.favoriteCount += data.isFavorited ? 1 : -1
}
}
updateFavoriteStatus(publicCharacters.value)
updateFavoriteStatus(myCharacters.value)
// 更新当前角色卡
if (currentCharacter.value?.id === characterId) {
currentCharacter.value.isFavorited = data.isFavorited
currentCharacter.value.favoriteCount += data.isFavorited ? 1 : -1
}
ElMessage.success(data.isFavorited ? '已收藏' : '已取消收藏')
return data.isFavorited
} catch (error) {
ElMessage.error('操作失败')
throw error
}
}
/**
* 点赞角色卡
*/
async function likeCharacter(characterId: number) {
try {
await characterApi.likeCharacter(characterId)
// 更新列表中的点赞数
const updateLikeCount = (list: Character[]) => {
const character = list.find(c => c.id === characterId)
if (character) {
character.totalLikes += 1
}
}
updateLikeCount(publicCharacters.value)
updateLikeCount(myCharacters.value)
// 更新当前角色卡
if (currentCharacter.value?.id === characterId) {
currentCharacter.value.totalLikes += 1
}
ElMessage.success('点赞成功')
} catch (error) {
ElMessage.error('点赞失败')
throw error
}
}
/**
* 导出角色卡为 JSON
*/
async function downloadCharacterJSON(id: number) {
try {
const character = publicCharacters.value.find(c => c.id === id) ||
myCharacters.value.find(c => c.id === id) ||
currentCharacter.value
const filename = character ? `${character.name}.json` : `character_${id}.json`
await characterApi.downloadCharacterJSON(id, filename)
ElMessage.success('导出成功')
} catch (error) {
ElMessage.error('导出失败')
throw error
}
}
/**
* 导出角色卡为 PNG
*/
async function downloadCharacterPNG(id: number) {
try {
const character = publicCharacters.value.find(c => c.id === id) ||
myCharacters.value.find(c => c.id === id) ||
currentCharacter.value
const filename = character ? `${character.name}.png` : `character_${id}.png`
await characterApi.downloadCharacterPNG(id, filename)
ElMessage.success('导出成功')
} catch (error) {
ElMessage.error('导出失败')
throw error
}
}
/**
* 导入角色卡
*/
async function importCharacter(file: File, isPublic: boolean = false) {
loading.value = true
try {
const { data } = await characterApi.importCharacter(file, isPublic)
ElMessage.success('导入成功')
// 添加到我的角色卡列表
myCharacters.value.unshift(data)
return data
} catch (error) {
ElMessage.error('导入失败')
throw error
} finally {
loading.value = false
}
}
return {
// 状态
publicCharacters,
myCharacters,
currentCharacter,
loading,
total,
page,
pageSize,
// 计算属性
hasMore,
// 方法
fetchPublicCharacters,
loadMore,
resetPagination,
fetchMyCharacters,
fetchCharacterDetail,
createCharacter,
updateCharacter,
deleteCharacter,
toggleFavorite,
likeCharacter,
downloadCharacterJSON,
downloadCharacterPNG,
importCharacter
}
})

79
web-app-vue/src/style.css Normal file
View File

@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

24
web-app-vue/src/types/api.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
/**
* API 响应类型定义
*/
// 统一响应格式
export interface ApiResponse<T = any> {
code: number
data: T
msg: string
}
// 分页请求参数
export interface PageRequest {
page: number
pageSize: number
}
// 分页响应
export interface PageResponse<T> {
list: T[]
total: number
page: number
pageSize: number
}

87
web-app-vue/src/types/character.d.ts vendored Normal file
View File

@@ -0,0 +1,87 @@
/**
* 角色卡相关类型定义
*/
// 角色卡
export interface Character {
id: number
name: string
description: string
personality: string
scenario: string
avatar: string
creatorId?: number
creatorName: string
creatorNotes: string
tags: string[]
isPublic: boolean
version: number
firstMessage: string
exampleMessages: string[]
totalChats: number
totalLikes: number
usageCount: number
favoriteCount: number
tokenCount: number
isFavorited: boolean
createdAt: string
updatedAt: string
}
// 角色卡列表响应
export interface CharacterListResponse {
list: Character[]
total: number
page: number
pageSize: number
}
// 角色卡列表请求参数
export interface CharacterListParams {
page?: number
pageSize?: number
keyword?: string
tags?: string[]
sortBy?: 'newest' | 'popular' | 'mostChats' | 'mostLikes'
}
// 创建角色卡请求
export interface CreateCharacterRequest {
name: string
description?: string
personality?: string
scenario?: string
avatar?: string
creatorName?: string
creatorNotes?: string
firstMessage?: string
exampleMessages?: string[]
tags?: string[]
isPublic: boolean
}
// 更新角色卡请求
export interface UpdateCharacterRequest extends CreateCharacterRequest {
id: number
}
// 角色卡导出数据
export interface CharacterExportData {
spec: string
spec_version: string
data: {
name: string
description: string
personality: string
scenario: string
first_mes: string
mes_example: string
creator_notes: string
system_prompt: string
post_history_instructions: string
tags: string[]
creator: string
character_version: number
extensions: Record<string, any>
}
}

61
web-app-vue/src/types/user.d.ts vendored Normal file
View File

@@ -0,0 +1,61 @@
/**
* 用户相关类型定义
*/
// 用户信息
export interface User {
id: number
uuid: string
username: string
nickName: string
email: string
phone: string
avatar: string
status: string
enable: boolean
lastLoginAt: string | null
chatCount: number
messageCount: number
aiSettings: Record<string, any> | null
preferences: Record<string, any> | null
createdAt: string
}
// 登录请求
export interface LoginRequest {
username: string
password: string
}
// 注册请求
export interface RegisterRequest {
username: string
password: string
nickName?: string
email?: string
phone?: string
}
// 登录响应
export interface LoginResponse {
user: User
token: string
refreshToken: string
expiresAt: number
}
// 修改密码请求
export interface ChangePasswordRequest {
oldPassword: string
newPassword: string
}
// 更新用户信息请求
export interface UpdateProfileRequest {
nickName?: string
email?: string
phone?: string
avatar?: string
preferences?: string
aiSettings?: string
}

View File

@@ -0,0 +1,84 @@
import axios, { type AxiosInstance, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import type { ApiResponse } from '@/types/api'
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: (import.meta.env?.VITE_API_BASE_URL as string) || 'http://localhost:8888',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 从 localStorage 获取 token
const token = localStorage.getItem('st_access_token')
if (token && config.headers) {
config.headers['x-token'] = token
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const res = response.data
// code 不为 0 表示业务错误
if (res.code !== 0) {
ElMessage.error(res.msg || '请求失败')
// 401 未授权,跳转到登录页
if (res.code === 401) {
localStorage.removeItem('st_access_token')
localStorage.removeItem('st_refresh_token')
localStorage.removeItem('st_user_info')
window.location.href = '/auth/login'
}
return Promise.reject(new Error(res.msg || '请求失败'))
}
return res
},
(error) => {
console.error('响应错误:', error)
let message = '网络错误'
if (error.response) {
switch (error.response.status) {
case 401:
message = '未授权,请重新登录'
localStorage.removeItem('st_access_token')
localStorage.removeItem('st_refresh_token')
localStorage.removeItem('st_user_info')
window.location.href = '/auth/login'
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求地址不存在'
break
case 500:
message = '服务器错误'
break
default:
message = error.response.data?.msg || '请求失败'
}
}
ElMessage.error(message)
return Promise.reject(error)
}
)
export default service

View File

@@ -0,0 +1,165 @@
<template>
<div class="login-container">
<el-card class="login-card" shadow="always">
<!-- Logo 和标题 -->
<div class="login-header">
<h1 class="title">云酒馆</h1>
<p class="subtitle"> AI 角色开启奇妙对话</p>
</div>
<!-- 登录表单 -->
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="login-button"
:loading="authStore.loading"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<!-- 底部链接 -->
<div class="login-footer">
<span>还没有账号</span>
<el-link type="primary" @click="goToRegister">立即注册</el-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import type { LoginRequest } from '@/types/user'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
// 表单数据
const loginForm = reactive<LoginRequest>({
username: '',
password: '',
})
// 表单验证规则
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 32, message: '用户名长度为 3-32 个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 32, message: '密码长度为 6-32 个字符', trigger: 'blur' },
],
}
// 处理登录
async function handleLogin() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
const success = await authStore.handleLogin(loginForm)
if (success) {
// 登录成功,跳转到首页
router.push('/')
}
})
}
// 跳转到注册页
function goToRegister() {
router.push('/auth/register')
}
</script>
<style scoped lang="scss">
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.login-card {
width: 100%;
max-width: 420px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
.login-header {
text-align: center;
margin-bottom: 32px;
.title {
font-size: 32px;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.login-button {
width: 100%;
}
.login-footer {
text-align: center;
font-size: 14px;
color: var(--el-text-color-secondary);
span {
margin-right: 8px;
}
}
}
@media (max-width: 640px) {
.login-card {
margin: 0 16px;
}
}
</style>

View File

@@ -0,0 +1,225 @@
<template>
<div class="register-container">
<el-card class="register-card" shadow="always">
<!-- Logo 和标题 -->
<div class="register-header">
<h1 class="title">注册账号</h1>
<p class="subtitle">加入云酒馆开启你的 AI 对话之旅</p>
</div>
<!-- 注册表单 -->
<el-form
ref="formRef"
:model="registerForm"
:rules="rules"
size="large"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="用户名3-32个字符"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="密码6-32个字符"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="确认密码"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item prop="nickName">
<el-input
v-model="registerForm.nickName"
placeholder="昵称(可选)"
:prefix-icon="UserFilled"
clearable
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
placeholder="邮箱(可选,用于找回密码)"
:prefix-icon="Message"
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="register-button"
:loading="authStore.loading"
@click="handleRegister"
>
注册
</el-button>
</el-form-item>
</el-form>
<!-- 底部链接 -->
<div class="register-footer">
<span>已有账号</span>
<el-link type="primary" @click="goToLogin">立即登录</el-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock, UserFilled, Message } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import type { RegisterRequest } from '@/types/user'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
// 表单数据
interface RegisterForm extends RegisterRequest {
confirmPassword: string
}
const registerForm = reactive<RegisterForm>({
username: '',
password: '',
confirmPassword: '',
nickName: '',
email: '',
})
// 验证确认密码
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
// 表单验证规则
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 32, message: '用户名长度为 3-32 个字符', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: '用户名只能包含字母、数字和下划线',
trigger: 'blur',
},
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 32, message: '密码长度为 6-32 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, validator: validateConfirmPassword, trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
}
// 处理注册
async function handleRegister() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
// 移除 confirmPassword 字段
const { confirmPassword, ...registerData } = registerForm
const success = await authStore.handleRegister(registerData)
if (success) {
// 注册成功,跳转到登录页
router.push('/auth/login')
}
})
}
// 跳转到登录页
function goToLogin() {
router.push('/auth/login')
}
</script>
<style scoped lang="scss">
.register-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.register-card {
width: 100%;
max-width: 420px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
.register-header {
text-align: center;
margin-bottom: 32px;
.title {
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.register-button {
width: 100%;
}
.register-footer {
text-align: center;
font-size: 14px;
color: var(--el-text-color-secondary);
span {
margin-right: 8px;
}
}
}
@media (max-width: 640px) {
.register-card {
margin: 0 16px;
}
}
</style>

View File

@@ -0,0 +1,426 @@
<template>
<div v-loading="loading" class="character-detail">
<template v-if="character">
<!-- 角色头部 -->
<div class="character-header">
<div class="character-avatar-large">
<img :src="character.avatar || '/default-avatar.png'" :alt="character.name" />
</div>
<div class="character-header-info">
<h1 class="character-title">{{ character.name }}</h1>
<div class="character-meta">
<span v-if="character.creatorName" class="meta-item">
<el-icon><User /></el-icon>
{{ character.creatorName }}
</span>
<span class="meta-item">
<el-icon><Clock /></el-icon>
{{ formatDate(character.createdAt) }}
</span>
<span class="meta-item">
<el-icon><View /></el-icon>
使用 {{ character.usageCount }}
</span>
</div>
<!-- 操作按钮 -->
<div class="character-actions">
<el-button
type="primary"
:icon="ChatLineSquare"
@click="startChat"
>
开始对话
</el-button>
<el-button
:type="character.isFavorited ? 'warning' : 'default'"
:icon="character.isFavorited ? StarFilled : Star"
@click="handleFavorite"
>
{{ character.isFavorited ? '已收藏' : '收藏' }}
</el-button>
<el-button
:icon="Like"
@click="handleLike"
>
点赞 {{ character.totalLikes }}
</el-button>
<el-dropdown @command="handleExport">
<el-button :icon="Download">
导出
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">导出为 JSON</el-dropdown-item>
<el-dropdown-item command="png">导出为 PNG</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="isOwner"
:icon="Edit"
@click="goToEdit"
>
编辑
</el-button>
<el-button
v-if="isOwner"
type="danger"
:icon="Delete"
@click="handleDelete"
>
删除
</el-button>
</div>
</div>
</div>
<!-- 标签 -->
<div v-if="character.tags && character.tags.length > 0" class="character-tags-section">
<el-tag
v-for="tag in character.tags"
:key="tag"
type="info"
effect="plain"
>
{{ tag }}
</el-tag>
</div>
<!-- 详细信息 -->
<div class="character-details">
<el-tabs>
<el-tab-pane label="基本信息">
<div class="detail-section">
<h3>角色描述</h3>
<p class="detail-text">{{ character.description || '暂无描述' }}</p>
</div>
<div class="detail-section">
<h3>性格特点</h3>
<p class="detail-text">{{ character.personality || '暂无性格描述' }}</p>
</div>
<div class="detail-section">
<h3>场景设定</h3>
<p class="detail-text">{{ character.scenario || '暂无场景设定' }}</p>
</div>
</el-tab-pane>
<el-tab-pane label="对话示例">
<div class="detail-section">
<h3>第一条消息</h3>
<div class="message-box">
{{ character.firstMessage || '暂无第一条消息' }}
</div>
</div>
<div v-if="character.exampleMessages && character.exampleMessages.length > 0" class="detail-section">
<h3>示例对话</h3>
<div
v-for="(msg, index) in character.exampleMessages"
:key="index"
class="message-box"
>
{{ msg }}
</div>
</div>
</el-tab-pane>
<el-tab-pane label="统计信息">
<el-descriptions :column="2" border>
<el-descriptions-item label="对话总数">
{{ character.totalChats }}
</el-descriptions-item>
<el-descriptions-item label="点赞总数">
{{ character.totalLikes }}
</el-descriptions-item>
<el-descriptions-item label="使用次数">
{{ character.usageCount }}
</el-descriptions-item>
<el-descriptions-item label="收藏次数">
{{ character.favoriteCount }}
</el-descriptions-item>
<el-descriptions-item label="Token 数量">
{{ character.tokenCount }}
</el-descriptions-item>
<el-descriptions-item label="角色版本">
v{{ character.version }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(character.createdAt) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ formatDateTime(character.updatedAt) }}
</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane v-if="character.creatorNotes" label="创建者备注">
<div class="detail-section">
<p class="detail-text">{{ character.creatorNotes }}</p>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import { useAuthStore } from '@/stores/auth'
import {
User,
Clock,
View,
ChatLineSquare,
Star,
StarFilled,
Like,
Download,
Edit,
Delete,
ArrowDown
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const characterStore = useCharacterStore()
const authStore = useAuthStore()
const loading = ref(false)
const character = computed(() => characterStore.currentCharacter)
// 是否是创建者
const isOwner = computed(() => {
return authStore.isLoggedIn &&
character.value?.creatorId === authStore.user?.id
})
// 格式化日期
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN')
}
function formatDateTime(dateStr: string) {
return new Date(dateStr).toLocaleString('zh-CN')
}
// 开始对话
function startChat() {
ElMessage.info('对话功能开发中...')
// TODO: router.push(`/chat/${character.value?.id}`)
}
// 切换收藏
async function handleFavorite() {
if (!authStore.isLoggedIn) {
ElMessage.warning('请先登录')
return
}
if (character.value) {
await characterStore.toggleFavorite(character.value.id)
}
}
// 点赞
async function handleLike() {
if (character.value) {
await characterStore.likeCharacter(character.value.id)
}
}
// 导出
async function handleExport(command: string) {
if (!character.value) return
if (command === 'json') {
await characterStore.downloadCharacterJSON(character.value.id)
} else if (command === 'png') {
await characterStore.downloadCharacterPNG(character.value.id)
}
}
// 编辑
function goToEdit() {
if (character.value) {
router.push(`/character/${character.value.id}/edit`)
}
}
// 删除
async function handleDelete() {
if (!character.value) return
try {
await ElMessageBox.confirm(
'确定要删除这个角色卡吗?此操作不可恢复。',
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await characterStore.deleteCharacter(character.value.id)
router.push('/')
} catch (error) {
// 用户取消
}
}
// 初始化
onMounted(async () => {
const characterId = Number(route.params.id)
if (characterId) {
loading.value = true
try {
await characterStore.fetchCharacterDetail(characterId)
} finally {
loading.value = false
}
}
})
</script>
<style scoped lang="scss">
.character-detail {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.character-header {
display: flex;
gap: 32px;
margin-bottom: 32px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@media (max-width: 768px) {
flex-direction: column;
gap: 20px;
}
}
.character-avatar-large {
flex-shrink: 0;
width: 240px;
height: 320px;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
width: 100%;
height: 400px;
}
}
.character-header-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.character-title {
font-size: 32px;
font-weight: 600;
color: #303133;
margin: 0;
}
.character-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
color: #909399;
font-size: 14px;
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
}
.character-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: auto;
}
.character-tags-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 16px 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.character-details {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.detail-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
}
.detail-text {
font-size: 14px;
line-height: 1.8;
color: #606266;
white-space: pre-wrap;
margin: 0;
}
.message-box {
background: #f5f7fa;
border-radius: 8px;
padding: 16px;
font-size: 14px;
line-height: 1.6;
color: #606266;
white-space: pre-wrap;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<div class="character-edit-page">
<div class="page-header">
<h2>{{ isEdit ? '编辑角色卡' : '创建角色卡' }}</h2>
<div class="header-actions">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</div>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
class="character-form"
>
<el-card class="form-section">
<template #header>
<span>基本信息</span>
</template>
<el-form-item label="角色名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入角色名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="角色头像" prop="avatar">
<el-input
v-model="formData.avatar"
placeholder="请输入头像URL"
/>
<div v-if="formData.avatar" class="avatar-preview">
<img :src="formData.avatar" alt="预览" />
</div>
</el-form-item>
<el-form-item label="创建者名称">
<el-input
v-model="formData.creatorName"
placeholder="请输入创建者名称"
/>
</el-form-item>
<el-form-item label="是否公开">
<el-switch v-model="formData.isPublic" />
<span class="form-tip">公开后其他用户可以查看和使用此角色卡</span>
</el-form-item>
</el-card>
<el-card class="form-section">
<template #header>
<span>角色描述</span>
</template>
<el-form-item label="角色描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="4"
placeholder="请输入角色的基本描述..."
/>
</el-form-item>
<el-form-item label="性格特点">
<el-input
v-model="formData.personality"
type="textarea"
:rows="4"
placeholder="描述角色的性格、特点、行为习惯等..."
/>
</el-form-item>
<el-form-item label="场景设定">
<el-input
v-model="formData.scenario"
type="textarea"
:rows="4"
placeholder="描述角色所处的场景、背景故事等..."
/>
</el-form-item>
</el-card>
<el-card class="form-section">
<template #header>
<span>对话设置</span>
</template>
<el-form-item label="第一条消息">
<el-input
v-model="formData.firstMessage"
type="textarea"
:rows="4"
placeholder="角色在对话开始时的第一条消息..."
/>
</el-form-item>
<el-form-item label="示例对话">
<div class="example-messages">
<div
v-for="(msg, index) in formData.exampleMessages"
:key="index"
class="example-message-item"
>
<el-input
v-model="formData.exampleMessages[index]"
type="textarea"
:rows="3"
placeholder="输入示例对话..."
/>
<el-button
type="danger"
:icon="Delete"
circle
@click="removeExampleMessage(index)"
/>
</div>
<el-button
type="primary"
:icon="Plus"
plain
@click="addExampleMessage"
>
添加示例对话
</el-button>
</div>
</el-form-item>
</el-card>
<el-card class="form-section">
<template #header>
<span>标签和备注</span>
</template>
<el-form-item label="标签">
<el-select
v-model="formData.tags"
multiple
filterable
allow-create
placeholder="输入标签并回车添加"
class="tag-select"
>
<el-option
v-for="tag in commonTags"
:key="tag"
:label="tag"
:value="tag"
/>
</el-select>
</el-form-item>
<el-form-item label="创建者备注">
<el-input
v-model="formData.creatorNotes"
type="textarea"
:rows="4"
placeholder="添加一些创建者的备注信息..."
/>
</el-form-item>
</el-card>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import { Plus, Delete } from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { CreateCharacterRequest, UpdateCharacterRequest } from '@/types/character'
const route = useRoute()
const router = useRouter()
const characterStore = useCharacterStore()
const formRef = ref<FormInstance>()
const saving = ref(false)
// 是否是编辑模式
const isEdit = computed(() => !!route.params.id)
// 表单数据
const formData = reactive<CreateCharacterRequest>({
name: '',
description: '',
personality: '',
scenario: '',
avatar: '',
creatorName: '',
creatorNotes: '',
firstMessage: '',
exampleMessages: [],
tags: [],
isPublic: false
})
// 常用标签
const commonTags = [
'奇幻',
'科幻',
'现代',
'历史',
'冒险',
'浪漫',
'悬疑',
'喜剧',
'恐怖',
'游戏',
'动漫',
'电影',
'小说',
'原创'
]
// 表单验证规则
const rules = reactive<FormRules>({
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
]
})
// 添加示例对话
function addExampleMessage() {
formData.exampleMessages?.push('')
}
// 删除示例对话
function removeExampleMessage(index: number) {
formData.exampleMessages?.splice(index, 1)
}
// 返回
function goBack() {
router.back()
}
// 保存
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (error) {
ElMessage.error('请检查表单填写')
return
}
saving.value = true
try {
if (isEdit.value) {
// 编辑
const updateData: UpdateCharacterRequest = {
id: Number(route.params.id),
...formData
}
await characterStore.updateCharacter(updateData)
router.push(`/character/${route.params.id}`)
} else {
// 创建
const character = await characterStore.createCharacter(formData)
router.push(`/character/${character.id}`)
}
} catch (error) {
console.error('保存失败:', error)
} finally {
saving.value = false
}
}
// 初始化
onMounted(async () => {
if (isEdit.value) {
// 编辑模式:加载角色卡数据
const characterId = Number(route.params.id)
await characterStore.fetchCharacterDetail(characterId)
const character = characterStore.currentCharacter
if (character) {
Object.assign(formData, {
name: character.name,
description: character.description,
personality: character.personality,
scenario: character.scenario,
avatar: character.avatar,
creatorName: character.creatorName,
creatorNotes: character.creatorNotes,
firstMessage: character.firstMessage,
exampleMessages: [...(character.exampleMessages || [])],
tags: [...(character.tags || [])],
isPublic: character.isPublic
})
}
}
})
</script>
<style scoped lang="scss">
.character-edit-page {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.character-form {
.form-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.form-tip {
margin-left: 12px;
font-size: 13px;
color: #909399;
}
.avatar-preview {
margin-top: 12px;
width: 200px;
height: 267px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #dcdfe6;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.example-messages {
width: 100%;
.example-message-item {
display: flex;
gap: 12px;
margin-bottom: 12px;
align-items: flex-start;
.el-input {
flex: 1;
}
}
}
.tag-select {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="my-characters-page">
<div class="page-header">
<h2>我的角色卡</h2>
<div class="header-actions">
<el-upload
:show-file-list="false"
:before-upload="handleImport"
accept=".png,.json"
>
<el-button :icon="Upload">导入角色卡</el-button>
</el-upload>
<el-button type="primary" :icon="Plus" @click="goToCreate">
创建角色卡
</el-button>
</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索我的角色卡..."
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 角色卡列表 -->
<div v-loading="loading" class="character-grid">
<div
v-for="character in characters"
:key="character.id"
class="character-card"
>
<!-- 头像 -->
<div class="character-avatar" @click="goToDetail(character.id)">
<img
:src="character.avatar || '/default-avatar.png'"
:alt="character.name"
/>
<!-- 公开状态标识 -->
<el-tag
:type="character.isPublic ? 'success' : 'info'"
size="small"
class="public-tag"
>
{{ character.isPublic ? '公开' : '私密' }}
</el-tag>
</div>
<!-- 信息 -->
<div class="character-info">
<div class="character-name">{{ character.name }}</div>
<div class="character-stats">
<span><el-icon><ChatDotSquare /></el-icon> {{ character.totalChats }}</span>
<span><el-icon><StarFilled /></el-icon> {{ character.totalLikes }}</span>
</div>
<!-- 操作按钮 -->
<div class="character-actions">
<el-button size="small" :icon="View" @click="goToDetail(character.id)">
查看
</el-button>
<el-button size="small" :icon="Edit" @click="goToEdit(character.id)">
编辑
</el-button>
<el-button
size="small"
type="danger"
:icon="Delete"
@click="handleDelete(character.id)"
>
删除
</el-button>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && characters.length === 0"
description="还没有创建角色卡"
>
<el-button type="primary" @click="goToCreate">立即创建</el-button>
</el-empty>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import {
Plus,
Upload,
Search,
View,
Edit,
Delete,
ChatDotSquare,
StarFilled
} from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import type { UploadRawFile } from 'element-plus'
const router = useRouter()
const characterStore = useCharacterStore()
const searchKeyword = ref('')
const loading = computed(() => characterStore.loading)
const characters = computed(() => characterStore.myCharacters)
// 搜索
function handleSearch() {
characterStore.fetchMyCharacters({
keyword: searchKeyword.value
})
}
// 跳转到创建页面
function goToCreate() {
router.push('/character/create')
}
// 跳转到详情页
function goToDetail(id: number) {
router.push(`/character/${id}`)
}
// 跳转到编辑页
function goToEdit(id: number) {
router.push(`/character/${id}/edit`)
}
// 删除角色卡
async function handleDelete(id: number) {
try {
await ElMessageBox.confirm(
'确定要删除这个角色卡吗?此操作不可恢复。',
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await characterStore.deleteCharacter(id)
} catch (error) {
// 用户取消
}
}
// 导入角色卡
async function handleImport(file: UploadRawFile) {
try {
// 检查文件类型
const validTypes = ['image/png', 'application/json']
if (!validTypes.includes(file.type)) {
ElMessage.error('只支持 PNG 或 JSON 格式的角色卡文件')
return false
}
// 检查文件大小(最大 10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB')
return false
}
// 导入角色卡
await characterStore.importCharacter(file, false)
// 刷新列表
await characterStore.fetchMyCharacters()
} catch (error) {
console.error('导入失败:', error)
}
return false // 阻止自动上传
}
// 初始化
onMounted(() => {
characterStore.fetchMyCharacters()
})
</script>
<style scoped lang="scss">
.my-characters-page {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.search-bar {
margin-bottom: 24px;
.el-input {
max-width: 500px;
}
}
.character-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
}
.character-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
}
.character-avatar {
position: relative;
width: 100%;
padding-top: 133.33%;
overflow: hidden;
cursor: pointer;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.public-tag {
position: absolute;
top: 8px;
right: 8px;
}
}
.character-info {
padding: 16px;
}
.character-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.character-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: #909399;
margin-bottom: 12px;
span {
display: flex;
align-items: center;
gap: 4px;
}
}
.character-actions {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,333 @@
<template>
<div class="character-list-page">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索角色卡..."
class="search-input"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="sortBy"
placeholder="排序方式"
class="sort-select"
@change="handleSearch"
>
<el-option label="最新" value="newest" />
<el-option label="最热门" value="popular" />
<el-option label="对话最多" value="mostChats" />
<el-option label="点赞最多" value="mostLikes" />
</el-select>
</div>
<!-- 角色卡瀑布流 -->
<div v-loading="loading && characters.length === 0" class="character-grid">
<div
v-for="character in characters"
:key="character.id"
class="character-card"
@click="goToDetail(character.id)"
>
<!-- 角色头像 -->
<div class="character-avatar">
<img
:src="character.avatar || '/default-avatar.png'"
:alt="character.name"
@error="handleImageError"
/>
<!-- 悬浮操作按钮 -->
<div class="card-actions">
<el-button
circle
size="small"
:icon="character.isFavorited ? Star : StarFilled"
@click.stop="handleFavorite(character.id)"
/>
<el-button
circle
size="small"
:icon="ChatLineSquare"
@click.stop="startChat(character.id)"
/>
</div>
</div>
<!-- 角色信息 -->
<div class="character-info">
<div class="character-name">{{ character.name }}</div>
<div v-if="character.creatorName" class="character-creator">
{{ character.creatorName }}
</div>
<div v-if="character.description" class="character-desc">
{{ character.description }}
</div>
<!-- 标签 -->
<div v-if="character.tags && character.tags.length > 0" class="character-tags">
<el-tag
v-for="tag in character.tags.slice(0, 3)"
:key="tag"
size="small"
type="info"
>
{{ tag }}
</el-tag>
</div>
<!-- 统计信息 -->
<div class="character-stats">
<span class="stat-item">
<el-icon><ChatDotSquare /></el-icon>
{{ character.totalChats }}
</span>
<span class="stat-item">
<el-icon><StarFilled /></el-icon>
{{ character.totalLikes }}
</span>
</div>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more">
<el-button
:loading="loading"
type="primary"
@click="loadMore"
>
加载更多
</el-button>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && characters.length === 0"
description="暂无角色卡"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import { Search, Star, StarFilled, ChatLineSquare, ChatDotSquare } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
const characterStore = useCharacterStore()
// 搜索和排序
const searchKeyword = ref('')
const sortBy = ref('newest')
// 计算属性
const characters = computed(() => characterStore.publicCharacters)
const loading = computed(() => characterStore.loading)
const hasMore = computed(() => characterStore.hasMore)
// 搜索
function handleSearch() {
characterStore.resetPagination()
characterStore.fetchPublicCharacters({
keyword: searchKeyword.value,
sortBy: sortBy.value
})
}
// 加载更多
function loadMore() {
characterStore.loadMore({
keyword: searchKeyword.value,
sortBy: sortBy.value
})
}
// 跳转到详情页
function goToDetail(id: number) {
router.push(`/character/${id}`)
}
// 切换收藏
async function handleFavorite(id: number) {
try {
await characterStore.toggleFavorite(id)
} catch (error) {
console.error('收藏失败:', error)
}
}
// 开始对话
function startChat(id: number) {
ElMessage.info('对话功能开发中...')
// TODO: 实现对话功能
// router.push(`/chat/${id}`)
}
// 图片加载失败处理
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
target.src = '/default-avatar.png'
}
// 初始化
onMounted(() => {
characterStore.fetchPublicCharacters()
})
</script>
<style scoped lang="scss">
.character-list-page {
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 24px;
.search-input {
flex: 1;
max-width: 500px;
}
.sort-select {
width: 150px;
}
}
.character-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.character-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
.card-actions {
opacity: 1;
}
}
}
.character-avatar {
position: relative;
width: 100%;
padding-top: 133.33%; // 3:4 宽高比
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s ease;
.el-button {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
&:hover {
background: white;
}
}
}
}
.character-info {
padding: 16px;
}
.character-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.character-creator {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.character-desc {
font-size: 13px;
color: #606266;
line-height: 1.5;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.character-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.character-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: #909399;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
.el-icon {
font-size: 14px;
}
}
}
.load-more {
display: flex;
justify-content: center;
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<CharacterList />
</template>
<script setup lang="ts">
import CharacterList from './CharacterList.vue'
</script>

View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

31
web-app-vue/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

2
web-app-vue/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -0,0 +1,50 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/app': {
target: 'http://localhost:8888',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8888',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
'vue-vendor': ['vue', 'vue-router', 'pinia'],
},
},
},
},
});

View File

@@ -0,0 +1,51 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/app': {
target: 'http://localhost:8888',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8888',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
'vue-vendor': ['vue', 'vue-router', 'pinia'],
},
},
},
},
})

199
web-app-vue/启动指南.md Normal file
View File

@@ -0,0 +1,199 @@
# 云酒馆 Vue 前台 - 快速启动指南
## ✅ 已完成的功能
### 认证模块
- ✅ 用户登录
- ✅ 用户注册
- ✅ Token 管理
- ✅ 路由守卫
- ✅ 自动登录检查
### 页面
- ✅ 登录页面(/auth/login
- ✅ 注册页面(/auth/register
- ✅ 首页(/
### 技术栈
- ✅ Vue 3 + TypeScript
- ✅ Vite 5
- ✅ Element Plus
- ✅ Pinia
- ✅ Vue Router 4
- ✅ Axios
## 🚀 启动步骤
### 1. 启动 Go 后端
```bash
# 在项目根目录
cd server
go run main.go
```
**确认后端启动成功:**
- 看到日志:`register table success`
- 看到日志:`前台静态文件服务已启动`
- 访问http://localhost:8888/app/auth/login (应该返回 404 或错误,这是正常的)
### 2. 启动 Vue 前端
```bash
# 在项目根目录
cd web-app-vue
npm run dev
```
**确认前端启动成功:**
- 看到:`VITE v5.x.x ready in xxx ms`
- 看到:`Local: http://localhost:3000/`
- 看到:`Network: use --host to expose`
### 3. 访问应用
打开浏览器访问:**http://localhost:3000**
应该会自动跳转到登录页:**http://localhost:3000/auth/login**
## 🧪 测试流程
### 第一步:注册新用户
1. 点击「立即注册」链接,或直接访问 http://localhost:3000/auth/register
2. 填写注册信息:
```
用户名test001
密码123456
确认密码123456
昵称:测试用户(可选)
邮箱test@example.com可选
```
3. 点击「注册」按钮
4. 看到成功提示:「注册成功,请登录」
5. 自动跳转到登录页
### 第二步:登录
1. 在登录页输入刚才注册的信息:
```
用户名test001
密码123456
```
2. 点击「登录」按钮
3. 看到成功提示:「登录成功」
4. 自动跳转到首页
### 第三步:查看首页
1. 应该看到欢迎信息:「欢迎回来,测试用户!」
2. 看到用户统计数据(对话数量、消息数量等)
3. 右上角显示用户头像和昵称
### 第四步:退出登录
1. 点击右上角用户头像
2. 选择「退出登录」
3. 看到提示:「已退出登录」
4. 自动跳转到登录页
### 第五步:测试路由守卫
1. 退出登录状态
2. 直接访问 http://localhost:3000/
3. 应该自动跳转到 http://localhost:3000/auth/login
4. 看到提示:「请先登录」
## 📊 端口说明
| 服务 | 端口 | 地址 |
|-----|------|------|
| Go 后端 | 8888 | http://localhost:8888 |
| Vue 前端 | 3000 | http://localhost:3000 |
| API 代理 | - | /app/* → http://localhost:8888 |
## 🔍 调试技巧
### 查看网络请求
1. 打开浏览器开发者工具F12
2. 切换到 Network 标签
3. 筛选 Fetch/XHR
4. 查看登录/注册的请求和响应
### 查看存储的数据
在浏览器控制台运行:
```javascript
// 查看 Token
console.log(localStorage.getItem('st_access_token'))
// 查看用户信息
console.log(JSON.parse(localStorage.getItem('st_user_info')))
// 清除登录状态
localStorage.clear()
```
### 查看 Pinia Store 状态
安装 Vue DevTools 浏览器扩展,可以查看:
- Pinia Stores 状态
- 组件树
- 路由信息
## ❗ 常见问题
### Q1: 启动前端后,页面空白?
**A:** 检查浏览器控制台是否有错误。可能原因:
- 路由配置问题
- 组件导入路径错误
- 刷新页面试试
### Q2: 登录时提示「网络错误」?
**A:** 检查:
1. Go 后端是否正在运行
2. 后端地址是否正确http://localhost:8888
3. 浏览器控制台的网络请求详情
4. 是否有 CORS 错误
### Q3: 点击登录没反应?
**A:** 检查:
1. 浏览器控制台是否有错误
2. 表单验证是否通过
3. 网络请求是否发出
### Q4: 登录成功但没跳转?
**A:** 检查:
1. 路由配置是否正确
2. 浏览器控制台是否有路由错误
3. localStorage 是否保存了 token
## 📝 开发技巧
### 热更新
修改代码后,浏览器会自动刷新,无需手动刷新。
### 查看类型提示
在 VSCode 中,鼠标悬停在变量上可以看到类型信息。
### 快捷键
- `Ctrl + C` - 停止开发服务器
- `Ctrl + Shift + I` - 打开浏览器开发者工具
## 🎯 下一步开发
### 立即可做
1. 测试注册和登录功能
2. 检查 UI 效果
3. 优化样式细节
### 后续开发
1. 角色管理模块
2. 对话功能模块
3. 设置页面
4. WebSocket 集成
## 🎉 恭喜
Vue 认证模块已经完成!你现在拥有:
- 🎨 现代化的 UI
- 🔐 完整的认证流程
- 💪 类型安全的代码
- 🏗️ 清晰的项目结构
**开始测试吧!** 🚀
---
**创建日期**: 2026-02-10
**版本**: v1.0.0