🎨 重构用户端前端为vue开发,完善基础类和角色相关接口
This commit is contained in:
3
web-app-vue/.env.development
Normal file
3
web-app-vue/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://localhost:8888
|
||||
VITE_WS_URL=ws://localhost:8888
|
||||
3
web-app-vue/.env.production
Normal file
3
web-app-vue/.env.production
Normal 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
24
web-app-vue/.gitignore
vendored
Normal 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
3
web-app-vue/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
35
web-app-vue/README.md
Normal file
35
web-app-vue/README.md
Normal 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
13
web-app-vue/index.html
Normal 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
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
30
web-app-vue/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
web-app-vue/public/vite.svg
Normal file
1
web-app-vue/public/vite.svg
Normal 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
22
web-app-vue/src/App.vue
Normal 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>
|
||||
85
web-app-vue/src/api/auth.ts
Normal file
85
web-app-vue/src/api/auth.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
128
web-app-vue/src/api/character.ts
Normal file
128
web-app-vue/src/api/character.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
}
|
||||
50
web-app-vue/src/assets/styles/index.scss
Normal file
50
web-app-vue/src/assets/styles/index.scss
Normal 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;
|
||||
}
|
||||
1
web-app-vue/src/assets/vue.svg
Normal file
1
web-app-vue/src/assets/vue.svg
Normal 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
90
web-app-vue/src/auto-imports.d.ts
vendored
Normal 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
46
web-app-vue/src/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
41
web-app-vue/src/components/HelloWorld.vue
Normal file
41
web-app-vue/src/components/HelloWorld.vue
Normal 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>
|
||||
117
web-app-vue/src/layouts/AuthLayout.vue
Normal file
117
web-app-vue/src/layouts/AuthLayout.vue
Normal 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>
|
||||
173
web-app-vue/src/layouts/DefaultLayout.vue
Normal file
173
web-app-vue/src/layouts/DefaultLayout.vue
Normal 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
19
web-app-vue/src/main.ts
Normal 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')
|
||||
95
web-app-vue/src/router/index.ts
Normal file
95
web-app-vue/src/router/index.ts
Normal 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
|
||||
141
web-app-vue/src/stores/auth.ts
Normal file
141
web-app-vue/src/stores/auth.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
334
web-app-vue/src/stores/character.ts
Normal file
334
web-app-vue/src/stores/character.ts
Normal 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
79
web-app-vue/src/style.css
Normal 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
24
web-app-vue/src/types/api.d.ts
vendored
Normal 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
87
web-app-vue/src/types/character.d.ts
vendored
Normal 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
61
web-app-vue/src/types/user.d.ts
vendored
Normal 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
|
||||
}
|
||||
84
web-app-vue/src/utils/request.ts
Normal file
84
web-app-vue/src/utils/request.ts
Normal 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
|
||||
165
web-app-vue/src/views/auth/Login.vue
Normal file
165
web-app-vue/src/views/auth/Login.vue
Normal 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>
|
||||
225
web-app-vue/src/views/auth/Register.vue
Normal file
225
web-app-vue/src/views/auth/Register.vue
Normal 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>
|
||||
426
web-app-vue/src/views/character/Detail.vue
Normal file
426
web-app-vue/src/views/character/Detail.vue
Normal 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>
|
||||
381
web-app-vue/src/views/character/Edit.vue
Normal file
381
web-app-vue/src/views/character/Edit.vue
Normal 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>
|
||||
310
web-app-vue/src/views/character/MyCharacters.vue
Normal file
310
web-app-vue/src/views/character/MyCharacters.vue
Normal 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>
|
||||
333
web-app-vue/src/views/home/CharacterList.vue
Normal file
333
web-app-vue/src/views/home/CharacterList.vue
Normal 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>
|
||||
7
web-app-vue/src/views/home/Index.vue
Normal file
7
web-app-vue/src/views/home/Index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<CharacterList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CharacterList from './CharacterList.vue'
|
||||
</script>
|
||||
16
web-app-vue/tsconfig.app.json
Normal file
16
web-app-vue/tsconfig.app.json
Normal 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
31
web-app-vue/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
26
web-app-vue/tsconfig.node.json
Normal file
26
web-app-vue/tsconfig.node.json
Normal 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
2
web-app-vue/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
50
web-app-vue/vite.config.js
Normal file
50
web-app-vue/vite.config.js
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
51
web-app-vue/vite.config.ts
Normal file
51
web-app-vue/vite.config.ts
Normal 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
199
web-app-vue/启动指南.md
Normal 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
|
||||
Reference in New Issue
Block a user