refactor: 主要功能实现

目前的工作已经实现的功能:
- 基本 FastAPI 路由;
- 基本 AI 聊天和创作功能;
- 用户信息管理、权限验证、JWT 令牌签发和验证、端点保护;
- HTML 验证码邮件发送和验证码验证。
This commit is contained in:
2026-05-24 13:58:51 +08:00
parent f06de85257
commit 21f0d7725e
98 changed files with 6483 additions and 116 deletions
+35 -5
View File
@@ -1,13 +1,43 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import PageHeader from '@/components/PageHeader.vue'
import {dateZhCN, zhCN} from 'naive-ui'
import {useNowUser} from '@/stores/now-user.ts'
import {onMounted} from 'vue'
import {useHead} from "@unhead/vue";
const NOWUSER = useNowUser()
useHead({
titleTemplate: "%s | NayHome"
})
onMounted(async () => {
const user_id = localStorage.getItem('user-id')
const access_token = localStorage.getItem('access-token')
if (user_id && access_token) {
try {
await NOWUSER.loadUserInfo(Number(user_id), access_token)
} catch {
localStorage.removeItem("user-id")
localStorage.removeItem('access-token')
console.log("已移除 localstorage 中存储的验证信息。")
}
}
})
</script>
<template>
<n-config-provider id="aapp">
<div class="header-container"></div>
<n-config-provider id="aapp" :date-locale="dateZhCN" :locale="zhCN">
<div class="header-container">
<page-header/>
</div>
<div class="content-container">
<router-view></router-view>
<n-message-provider>
<router-view></router-view>
</n-message-provider>
</div>
<div class="footer-container">🌸 Nya Home ~</div>
<n-global-style />
<n-global-style/>
</n-config-provider>
</template>
+39
View File
@@ -0,0 +1,39 @@
/* ===== 简约滚动条美化 ===== */
/* 适用于 Webkit 内核(Chrome/Edge/Safari */
::-webkit-scrollbar {
width: 6px; /* 垂直滚动条宽度 */
height: 6px; /* 水平滚动条高度 */
}
::-webkit-scrollbar-track {
background: transparent; /* 轨道透明,极简 */
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15); /* 滑块半透明灰 */
border-radius: 3px; /* 小圆角 */
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25); /* 悬停稍微深一点 */
}
/* Firefox 兼容 */
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
}
/* ===== a 标签美化 ===== */
a {
color: inherit; /* 继承父元素颜色,不区分已访问 */
text-decoration: none; /* 无下划线 */
}
a:hover {
text-decoration: underline; /* hover 显示下划线 */
text-decoration-color: #22c55e; /* 绿色 */
text-decoration-thickness: 2px; /* 可选:下划线粗细 */
}
+66
View File
@@ -0,0 +1,66 @@
div.message {
position: relative;
padding: 4px 12px;
font-size: 1rem;
height: max-content;
.modify-button {
position: absolute;
bottom: 10px;
right: 10px;
}
.collapse-button {
position: absolute;
bottom: 10px;
right: 50px;
}
}
div.user-message {
border: 2px solid #e1ff20;
border-radius: 4px;
background: #fffbb1;
}
div.aii-message {
border: 2px solid #20ff54;
border-radius: 4px;
background: #b1ffd0;
}
div.aii-message-streaming {
border: 2px solid #20d2ff;
border-radius: 4px;
background: #b1f8ff;
div.thinking {
border: 1px solid #e9ff20;
border-radius: 4px;
padding: 4px 8px;
margin: 6px 3px;
background: rgb(34 197 94 / 0.2);
}
}
// 折叠消息
div.collapse {
overflow: hidden;
min-height: 80px;
}
div.xaml-block {
background: rgb(102 237 197 / 0.2);
border: 1px solid rgb(102 237 197);
border-radius: 4px;
padding: 10px;
margin-top: 6px;
.title {
margin: 0;
}
.text {
margin: 0;
}
}
+29 -1
View File
@@ -1,6 +1,22 @@
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
div#app {
height: 100%;
overflow: hidden;
}
div#aapp {
height: 100dvh;
max-height: 100vh;
overflow: hidden;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
@@ -14,5 +30,17 @@ div#aapp {
div.content-container {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
}
}
div.nyahome-card {
border-radius: 6px;
border: 1px solid rgb(44 44 44 / 0.4);
padding: 10px;
}
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
</script>
<template>
<div class="in-dev">
<n-text class="in-dev-title">功能开发中</n-text>
<n-text class="in-dev-content">
已经被画在饼上辽请耐心等待喵
</n-text>
</div>
</template>
<style scoped>
div.in-dev {
width: 100%;
height: 100px;
border-radius: 8px;
border: 2px solid #ffa600;
background: linear-gradient(20deg, #ffd699, #fff6e6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.in-dev-title {
font-size: 1.2rem;
}
.in-dev-content {
font-size: 0.9rem;
}
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts"></script>
<template>
<div id="page-header">
<n-text class="nav-text">🌸 Nya Home ~</n-text>
<router-link to="/" style="margin-left: auto">
<n-button secondary type="tertiary" size="large">首页</n-button>
</router-link>
<router-link to="/chatroom">
<n-button secondary type="tertiary" size="large">聊天室</n-button>
</router-link>
<router-link to="/marketplace">
<n-button secondary type="tertiary" size="large">剧本市场</n-button>
</router-link>
</div>
</template>
<style scoped lang="scss">
div#page-header {
padding: 4px;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
.nav-text {
font-size: 22px;
}
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import { createErrorBlock, createXamlBlock, type Xaml } from '@/components/xaml-block.tsx'
const showModal = defineModel('showModal', { required: true })
const mode = ref<'xaml' | 'visual'>('xaml')
const xamlContent = ref('')
/**
* 目前使用的是芒果手搓的一个极简 xaml 解析函数,不会语法验证,也不支持任何高级的 xaml 特性,甚至在遇到错误时不会报错而只是循环下去。
* 以后会尝试搓一个更厉害的,或者用上一个更厉害的方案。目前版本仅作原理验证。
*/
// 记录循环次数,在循环次数异常大时直接结束解析
let count_ = 0
function parseXamlsContent(content: string): Xaml[] {
const final_xamls: Xaml[] = []
let content_ = content
while (content_.indexOf('<') >= 0) {
count_ += 1
if (count_ > 100) {
throw TypeError('Xaml 解析循环超过上限次数,已终止。')
}
console.log(`(${count_})[接下来] ${content_}`)
const first_tag_start_start = content_.indexOf('<')
const first_tag_start_end = content_.indexOf('>')
const first_tag_start = content_.slice(first_tag_start_start, first_tag_start_end + 1)
const first_tag_name = content_.slice(first_tag_start_start + 1, first_tag_start_end)
const first_tag_end_start = content_.search(`</${first_tag_name}>`)
const first_tag_end_end = first_tag_end_start + first_tag_name.length + 2
const first_tag_end = content_.slice(first_tag_end_start, first_tag_end_end + 1)
console.log(
`(${count_})[解析标签] ${first_tag_start} ${first_tag_end} (${first_tag_start_start}/${first_tag_start_end} - ${first_tag_end_start}/${first_tag_end_end})`,
)
const sub_content_ = content_.slice(first_tag_start_end + 1, first_tag_end_start)
if (sub_content_.indexOf('<') >= 0) {
final_xamls.push({
name: first_tag_name,
message: sub_content_.slice(0, sub_content_.indexOf('<')),
subXamls: parseXamlsContent(sub_content_.slice(sub_content_.indexOf('<'))),
})
} else {
final_xamls.push({
name: first_tag_name,
message: sub_content_,
subXamls: [],
})
}
content_ = content_.slice(first_tag_end_end + 1)
}
return final_xamls
}
</script>
<template>
<n-modal
v-model:show="showModal"
title="Xaml 可视化"
preset="card"
content-scrollable
style="height: 60vh; width: 50vw"
:z-index="999"
draggable
>
<n-flex vertical>
<n-radio-group v-model:value="mode">
<n-radio-button value="xaml">Xaml</n-radio-button>
<n-radio-button value="visual">可视化</n-radio-button>
</n-radio-group>
<n-alert v-if="mode === 'xaml'" type="info">
Xaml 格式的提示词便于简洁地向 AI 提供复杂的设定你也可以不使用 Xaml 格式<br />
你可以拖动此模态框但不允许在保持本窗口开启的状态下从遮罩下方复制代码
</n-alert>
<n-input v-model:value="xamlContent" v-if="mode === 'xaml'" type="textarea" :rows="10" />
<component
v-if="mode === 'visual'"
:is="
() => {
count_ = 0
try {
return createXamlBlock(parseXamlsContent(xamlContent))
} catch (err) {
return createErrorBlock(err!.toString())
}
}
"
/>
</n-flex>
</n-modal>
</template>
<style scoped></style>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import {ref} from 'vue'
import {api} from '@/tools/web.ts'
import type {ReturnDto} from '@/types/response.ts'
import {useMessage} from 'naive-ui'
import VerifyCodeModal from '@/components/admin/VerifyCodeModal.vue'
import {useNowUser} from "@/stores/now-user.ts";
const MESSAGE = useMessage()
const NOWUSER = useNowUser()
const showModal = defineModel('showModal', {required: true})
const showVerifyCodeModal = ref(false)
const newEmail = ref("")
const verifyCode = ref("")
function sendEmail() {
api
.post('/admin/me/email-verify/send/', JSON.stringify({to: newEmail.value}))
.then((res) => res.data as ReturnDto)
.then((res) => {
if (res.success) {
MESSAGE.success('验证邮件已发送,请检查收件箱~')
showVerifyCodeModal.value = true
} else {
throw TypeError('未知原因后端错误')
}
})
.catch((err) => {
MESSAGE.error(`获取验证码失败:${err}`)
})
}
function verifyEmail() {
api.post('/admin/me/email-verify/', JSON.stringify({
to: newEmail.value,
verify_code: String(verifyCode.value).split(",").join(""),
}))
.then((res) => res.data as ReturnDto)
.then((res) => {
if (res.success) {
MESSAGE.success('邮件地址修改成功~')
showVerifyCodeModal.value = false
showModal.value = false
NOWUSER.email = newEmail.value
} else {
throw TypeError('未知原因后端错误')
}
})
.catch((err) => {
MESSAGE.error(`验证失败:${err}`)
})
}
</script>
<template>
<n-modal v-model:show="showModal" preset="card" style="width: 600px" title="修改邮件地址">
<n-form label-placement="left">
<n-p>你需要使用新的邮件地址接收一个验证码来完成修改</n-p>
<n-form-item path="to" label="新的邮件地址">
<n-input v-model:value="newEmail"/>
</n-form-item>
<n-flex>
<n-button type="warning" @click="sendEmail()">获取验证码</n-button>
<n-button type="tertiary" @click="showVerifyCodeModal = true">直接输入验证码</n-button>
<n-tag type="info">验证码有效期为 5 分钟且不允许多个同时有效</n-tag>
</n-flex>
</n-form>
</n-modal>
<verify-code-modal v-model:show-modal="showVerifyCodeModal" v-model:verify-code="verifyCode"
:verify="verifyEmail"/>
</template>
<style scoped></style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
defineProps<{
title: string;
}>()
</script>
<template>
<n-card :title>
<template #header-extra>
<slot name="extra"/>
</template>
<slot name="default"/>
<template #action>
<slot name="action"/>
</template>
</n-card>
</template>
<style scoped>
</style>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import {ref} from 'vue'
import {api} from '@/tools/web.js'
import type {ReturnDto} from '@/types/response.js'
import {useMessage} from 'naive-ui'
import {AxiosError} from 'axios'
import {useNowUser} from '@/stores/now-user.js'
const MESSAGE = useMessage()
const NOWUSER = useNowUser()
const page = ref<'login' | 'register'>('login')
const loginMethod = ref<'name' | 'email' | 'phone'>('name')
const loginForm = ref({
username: '',
password: '',
})
function login() {
api
.post(`/admin/login/${loginMethod.value}`, {
username: loginForm.value.username,
password: loginForm.value.password,
})
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
return data.result as { user_id: number; access_token: string }
} else {
throw TypeError('未知原因,后端登录业务失败。')
}
})
.then((result) => {
localStorage.setItem('user-id', String(result.user_id))
localStorage.setItem('access-token', result.access_token)
MESSAGE.success('登录成功~')
NOWUSER.loadUserInfo(result.user_id, result.access_token)
})
.catch((err) => {
let err_msg = '未知的错误'
if (err instanceof AxiosError) {
if (err.status === 404) {
err_msg = '用户名不存在,请检查。'
} else if (err.status === 401) {
err_msg = '用户名或密码错误,请检查。'
}
}
MESSAGE.error(`登录失败:${err_msg}`)
})
}
</script>
<template>
<div>
<div class="user-action nyahome-card" v-if="NOWUSER.isLogin" style="position: relative">
<img :src="NOWUSER.background_url" alt="User Background" class="user-action-background">
<div class="card-content" style="margin-top: auto; margin-bottom: 20px;">
<n-avatar :size="96" circle :src="NOWUSER.avatar_url"/>
<n-h2 style="margin: 0">
{{ NOWUSER.display_name ? NOWUSER.display_name : NOWUSER.name }}
</n-h2>
<n-tag type="primary">{{ NOWUSER.name }}</n-tag>
<n-flex class="card-input">
<router-link class="card-button" to="/admin">
<n-button type="primary" style="width: 100%" secondary>管理</n-button>
</router-link>
<router-link class="card-button" :to="`/user/${NOWUSER.id}`">
<n-button type="info" style="width: 100%" secondary>主页</n-button>
</router-link>
<router-link class="card-button" to="#">
<n-button type="error" style="width: 100%" secondary>注销</n-button>
</router-link>
</n-flex>
</div>
</div>
<div class="user-action nyahome-card" v-else>
<n-radio-group v-model:value="page">
<n-radio-button value="login">登录</n-radio-button>
<n-radio-button value="register">注册</n-radio-button>
</n-radio-group>
<div class="card-content" v-if="page === 'login'">
<n-avatar :size="96" circle/>
<n-radio-group v-model:value="loginMethod">
<n-radio-button value="name">用户名</n-radio-button>
<n-radio-button value="email">邮箱</n-radio-button>
<n-radio-button value="phone">手机</n-radio-button>
</n-radio-group>
<n-input v-model:value="loginForm.username" class="card-input" placeholder=""/>
<n-input
v-model:value="loginForm.password"
class="card-input"
placeholder="密码"
type="password"
show-password-toggle
/>
<n-flex class="card-input">
<n-button type="info" class="card-button" @click="login()">登录</n-button>
<n-button type="warning" class="card-button">忘记密码</n-button>
</n-flex>
</div>
<div class="card-content" v-else>
<n-avatar :size="96" circle/>
<n-input class="card-input" placeholder="用户名"/>
<n-input class="card-input" placeholder="密码" type="password" show-password-toggle/>
<n-flex class="card-input">
<n-button type="primary" class="card-button">注册</n-button>
</n-flex>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
div.user-action {
height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
.user-action-background {
position: absolute;
top: 0;
width: 100%;
height: 180px;
object-fit: cover;
border-radius: 6px 6px 0 0;
}
div.card-content {
width: 80%;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
.card-input {
width: 100%;
}
.card-button {
flex: 1;
}
}
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import {ref} from "vue";
import {api} from "@/tools/web.ts";
import {useNowUser} from "@/stores/now-user.ts";
import {useMessage} from "naive-ui";
import {useRouter} from "vue-router";
const ROUTER = useRouter();
const MESSAGE = useMessage()
const NOWUSER = useNowUser()
const showModal = defineModel("showModal", {required: true})
const changeForm = ref({
old_password: '',
new_password: '',
})
function change() {
api.post("/admin/me/password/", JSON.stringify(changeForm.value))
.then(() => {
MESSAGE.success("密码修改成功,请重新登录。")
NOWUSER.isLogin = false
localStorage.removeItem("user-id")
localStorage.removeItem("access-token")
ROUTER.push("/")
})
.catch((err) => {
MESSAGE.error(`密码修改失败:${err}`)
MESSAGE.warning("如果您忘记了原密码,请选择「忘记密码」。")
})
}
</script>
<template>
<n-modal style="width: 600px;" v-model:show="showModal" title="修改密码" preset="card">
<n-form label-align="right" label-placement="left" label-width="auto" :model="changeForm">
<n-form-item label="原密码" path="old_password">
<n-input v-model:value="changeForm.old_password"/>
</n-form-item>
<n-form-item label="新密码" path="new_password">
<n-input v-model:value="changeForm.new_password" type="password" show-password-toggle/>
</n-form-item>
<n-form-item label="确认修改">
<n-flex>
<n-button type="error" @click="change()">确认修改</n-button>
<n-tag type="warning" size="large">修改密码会注销所有已登录状态您将需要重新登录</n-tag>
</n-flex>
</n-form-item>
</n-form>
</n-modal>
</template>
<style scoped>
</style>
@@ -0,0 +1,22 @@
<script setup lang="ts">
const showModal = defineModel('showModal', { required: true })
const verifyCode = defineModel('verifyCode', { required: true })
const { verify } = defineProps<{
verify: () => void
}>()
</script>
<template>
<n-modal v-model:show="showModal" preset="card" style="width: 600px" title="输入验证码">
<n-form inline>
<n-form-item label="验证码">
<n-input-otp size="large" v-model:value="verifyCode" />
</n-form-item>
<n-form-item>
<n-button size="large" type="primary" secondary @click="verify()">验证</n-button>
</n-form-item>
</n-form>
</n-modal>
</template>
<style scoped></style>
@@ -0,0 +1,173 @@
<script setup lang="ts">
import {computed, onMounted, ref, watch} from 'vue'
import AiiProviderAddModal from '@/components/chatroom/AiiProviderAddModal.vue'
import {api} from '@/tools/web.js'
import type {ReturnDto} from '@/types/response.js'
import {type SelectOption, useMessage} from 'naive-ui'
import type {AiiProviderPublicWithoutKey} from '@/types/aii.js'
const MESSAGE = useMessage()
const showModal = defineModel<boolean>('showModal', {required: true})
const showAddProviderModal = ref(false)
const selectProvider = ref<number | null>(null)
const providers = ref<AiiProviderPublicWithoutKey[]>([])
const remoteModels = ref<string[]>([])
const addModelForm = ref({
id: 0,
model_name: '',
max_context_length: 0,
aii_provider_id: selectProvider.value,
})
watch(selectProvider, (newValue) => {
addModelForm.value.aii_provider_id = newValue
})
function loadProviders() {
api
.get('/aii/provider/')
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
return data.result as AiiProviderPublicWithoutKey[]
} else {
throw TypeError('因未知原因,后端业务失败。')
}
})
.then((result) => {
providers.value = result
MESSAGE.success(`成功加载了 ${result.length} 个模型提供商。`)
})
.catch((err) => {
MESSAGE.error(`获取模型提供商列表失败:${err}`)
})
}
const providerOptions = computed<SelectOption[]>(() => {
const options = [] as SelectOption[]
for (const ap of providers.value) {
options.push({
label: `[${ap.id}] [ ${ap.name} ] ( ${ap.base_url} )`,
value: ap.id,
})
}
return options
})
onMounted(() => {
loadProviders()
})
function onGetRemoteModels() {
api
.get(`/aii/provider/${selectProvider.value}/remote/models/`)
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
return data.result as string[]
} else {
throw TypeError('由于未知原因,后端业务错误。')
}
})
.then((models) => {
remoteModels.value = models
MESSAGE.success(`成功获取模型提供商 ${selectProvider.value}${models.length} 个模型。`)
})
.catch((err) => {
MESSAGE.error(`获取提供商的模型列表失败:${err}`)
})
}
function onCheck() {
api
.get(`/aii/provider/${selectProvider.value}/remote/model/${addModelForm.value.model_name}/`)
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
MESSAGE.success(`检测成功,模型 ${addModelForm.value.model_name} 可用。`)
} else {
MESSAGE.warning(`检测完成,模型 ${addModelForm.value.model_name} 不可用。`)
}
})
.catch((err) => {
MESSAGE.error(`检测过程出现问题:${err}`)
})
}
function onConfirm() {
api
.post('/aii/model/', JSON.stringify(addModelForm.value))
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
MESSAGE.success(`模型 ${addModelForm.value.model_name} 成功添加。`)
showModal.value = false
} else {
throw TypeError('因未知原因,后端业务失败。')
}
})
.catch((err) => {
MESSAGE.error(`添加模型失败:${err}`)
})
}
</script>
<template>
<n-modal v-model:show="showModal" preset="card" title="添加模型">
<n-form :model="addModelForm" label-placement="left" label-width="auto" label-align="right">
<n-form-item label="模型提供商" path="aii_provider_id">
<n-flex style="width: 100%" justify="right" align="center">
<n-select v-model:value="selectProvider" :options="providerOptions"/>
<n-tag round type="info">修改已添加的提供商请前往管理中心</n-tag>
<n-button secondary type="success" size="small" round @click="loadProviders()"
>刷新
</n-button
>
<n-button secondary type="warning" size="small" round @click="showAddProviderModal = true"
>添加
</n-button
>
</n-flex>
</n-form-item>
<n-form-item label="模型名称" path="model_name">
<n-flex style="width: 100%" justify="right" align="center">
<n-input v-model:value="addModelForm.model_name"/>
<n-flex style="overflow: auto">
<n-button
secondary
type="info"
size="small"
round
v-for="m in remoteModels"
v-bind:key="m"
@click="addModelForm.model_name = m"
>{{ m }}
</n-button
>
</n-flex>
<n-button secondary type="success" size="small" round @click="onGetRemoteModels()"
>获取模型列表
</n-button
>
</n-flex>
</n-form-item>
<n-form-item label="最大上下文" path="max_context_length">
<n-input-number v-model:value="addModelForm.max_context_length">
<template #suffix>K</template>
</n-input-number>
</n-form-item>
<n-form-item label="添加完成">
<n-flex>
<n-button secondary type="info" @click="onCheck()">检测</n-button>
<n-button secondary type="primary" @click="onConfirm()">确认</n-button>
</n-flex>
</n-form-item>
</n-form>
<aii-provider-add-modal v-model:show-modal="showAddProviderModal"/>
</n-modal>
</template>
<style scoped></style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import {ref} from 'vue'
import {api} from '@/tools/web.js'
import type {ReturnDto} from '@/types/response.js'
import {useMessage} from 'naive-ui'
const MESSAGE = useMessage()
const showModal = defineModel('showModal', {required: true})
const addProviderForm = ref({
id: 0,
name: '',
base_url: '',
api_key: '',
})
function onCheck() {
api
.post('/aii/remote/provider/check/', JSON.stringify(addProviderForm.value))
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
MESSAGE.success(`模型提供商检测成功,探测到 ${data.result} 个可用模型。`)
} else {
MESSAGE.warning('模型提供商检测失败,请确认 Base URI 与 API key 是否正确。')
}
})
.catch((err) => {
MESSAGE.error(`检测模型提供商时遇到未知的异常,请检查后端业务:${err}`)
})
}
function onConfirm() {
api
.post('/aii/provider/', JSON.stringify(addProviderForm.value))
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
MESSAGE.success(`已添加模型提供商 ${addProviderForm.value.name}`)
showModal.value = false
} else {
throw TypeError('后端业务表示添加模型提供商失败,但未提供原因。')
}
})
.catch((err) => {
MESSAGE.error(`添加模型提供商失败:${err}`)
})
}
</script>
<template>
<n-modal v-model:show="showModal" preset="card" title="添加模型提供商">
<n-form :model="addProviderForm" label-placement="left" label-width="auto" label-align="right">
<n-form-item label="名称" path="name">
<n-input v-model:value="addProviderForm.name"/>
</n-form-item>
<n-form-item label="Base URL" path="base_url">
<n-input v-model:value="addProviderForm.base_url"/>
</n-form-item>
<n-form-item label="API Key" path="api_key">
<n-input v-model:value="addProviderForm.api_key"/>
</n-form-item>
<n-form-item label="添加完成">
<n-flex>
<n-button secondary type="info" @click="onCheck()">检测</n-button>
<n-button secondary type="primary" @click="onConfirm()">确认</n-button>
</n-flex>
</n-form-item>
</n-form>
</n-modal>
</template>
<style scoped></style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import {computed, onMounted, ref} from 'vue'
import {api} from '@/tools/web.js'
import type {ReturnDto} from '@/types/response.js'
import {type SelectOption, useMessage} from 'naive-ui'
import AiiModelAddModal from '@/components/chatroom/AiiModelAddModal.vue'
import type {AiiModelPublic} from '@/types/aii.js'
import ChatPromptQuicker from '@/components/chatroom/ChatPromptQuicker.vue'
import ScriptDrawer from '@/components/chatroom/ScriptDrawer.vue'
const MESSAGE = useMessage()
const selectedModel = defineModel<number | null>('selectModel', {required: true})
const quickerPrompt = defineModel<string>('quickerPrompt', {required: true})
const {script} = defineProps<{
script: string
}>()
const showModal = ref(false)
const models = ref<AiiModelPublic[]>([])
const showScriptDrawer = ref(false)
const modelOptions = computed(() => {
const options = [] as SelectOption[]
for (const model of models.value) {
options.push({
value: model.id,
label: `[${model.provider_name}] ${model.model_name}`,
})
}
return options
})
function load() {
api
.get('/aii/model')
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
models.value = data.result as AiiModelPublic[]
} else {
throw TypeError('获取模型列表失败……')
}
})
.catch((err) => {
MESSAGE.error(`加载模型列表失败:${err}`)
})
}
onMounted(() => {
load()
})
</script>
<template>
<n-flex vertical>
<n-card title="模型">
<template #header-extra>
<n-flex>
<n-button secondary type="info" size="small" round @click="load()">刷新</n-button>
<n-button secondary type="warning" size="small" round @click="showModal = true">
添加
</n-button>
<n-button-group>
<n-button secondary type="primary" size="small" round>保存</n-button>
<n-button secondary type="tertiary" size="small" round>?</n-button>
</n-button-group>
</n-flex>
</template>
<n-select v-model:value="selectedModel" :options="modelOptions"/>
<aii-model-add-modal v-model:show-modal="showModal"/>
</n-card>
<chat-prompt-quicker v-model:prompt-prefix="quickerPrompt"/>
<n-card title="剧本">
<template #header-extra>故事设定 · 世界书</template>
<n-flex vertical>
<n-alert type="info">剧本模板功能仍在开发中暂不支持分享哦~</n-alert>
<n-button secondary type="info" @click="showScriptDrawer = true">
故事设定 · 世界书
</n-button>
</n-flex>
<script-drawer :script v-model:show-drawer="showScriptDrawer"/>
</n-card>
<n-card title="设置">
<template #header-extra>也许你不需要修改这里</template>
<n-flex vertical>
<n-button secondary type="primary">聊天室信息</n-button>
<n-button secondary type="info">系统设置</n-button>
</n-flex>
</n-card>
</n-flex>
</template>
<style scoped></style>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import {md} from '@/tools/md.js'
import {onMounted, ref, useTemplateRef} from 'vue'
const {role, msg} = defineProps<{
role: 'aii' | 'user'
msg: string
onMessageEdit: (oldMessage: string, newMessage: string, change: 'aii' | 'user') => void
onMessageDelete: (message: string, change: 'aii' | 'user') => void
}>()
const showModal = ref(false)
const showEditor = ref(false)
const editorMessage = ref('')
const self = useTemplateRef('self')
const isCollapse = ref(false)
function enableCollapse() {
self.value?.classList.add('collapse')
isCollapse.value = true
}
function disableCollapse() {
self.value?.classList.remove('collapse')
isCollapse.value = false
}
onMounted(() => {
editorMessage.value = msg
if (role === 'aii') {
enableCollapse()
}
})
</script>
<template>
<div :class="[`${role}-message`, 'message']" ref="self">
<p v-html="md.render(msg)"/>
<n-button class="modify-button" secondary type="info" circle @click="showModal = true">
</n-button>
<n-button
v-if="role === 'aii'"
class="collapse-button"
secondary
:type="isCollapse ? 'error' : 'info'"
circle
@click="
() => {
if (isCollapse) {
disableCollapse()
} else {
enableCollapse()
}
self!.scrollIntoView({ behavior: 'smooth' })
}
"
>
🪟
</n-button>
<n-modal
v-model:show="showModal"
:title="role === 'aii' ? 'AI 生成的内容' : '你输入的内容'"
preset="card"
content-scrollable
style="max-height: 60vh"
>
<n-h3 prefix="bar" v-if="showEditor">编辑中</n-h3>
<n-input v-if="showEditor" type="textarea" :rows="10" v-model:value="editorMessage"></n-input>
<n-code v-else :code="msg" word-wrap/>
<!--suppress VueUnrecognizedSlot -->
<template #footer>
<n-flex align="center" style="padding-top: 10px">
<n-button v-if="!showEditor" secondary type="info">复制</n-button>
<n-button v-if="!showEditor" secondary type="warning" @click="showEditor = true">
编辑
</n-button>
<n-button v-if="!showEditor" secondary type="error" @click="onMessageDelete(msg, role)">
删除
</n-button>
<n-button v-if="showEditor" secondary type="info" @click="showEditor = false">
取消
</n-button>
<n-button
v-if="showEditor"
secondary
type="primary"
@click="
() => {
onMessageEdit(msg, editorMessage, role)
showEditor = false
}
"
>
保存
</n-button>
<n-tag v-if="showEditor" type="warning">保存不会触发 AI 调用</n-tag>
</n-flex>
</template>
</n-modal>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const promptPrefix = defineModel<string>('promptPrefix', { required: true })
const quickerForm = ref({
length: 1000,
style: '第三人称全知视角,禁止打破第四面墙。',
})
function save() {
promptPrefix.value = `<要求><输出字数>${quickerForm.value.length}</输出字数><风格约束>${quickerForm.value.style}</风格约束></要求>`
}
onMounted(() => {
save()
})
</script>
<template>
<n-card title="快速调整">
<template #header-extra>
<n-button-group>
<n-button secondary type="primary" size="small" round @click="save()">保存</n-button>
<n-button secondary type="tertiary" size="small" round>?</n-button>
</n-button-group>
</template>
<n-form :model="quickerForm">
<n-form-item label="输出字数" path="length">
<n-input-number v-model:value="quickerForm.length" />
</n-form-item>
<n-form-item label="风格约束" path="style">
<n-input v-model:value="quickerForm.style" />
</n-form-item>
</n-form>
</n-card>
</template>
<style scoped></style>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import {createChatTableMessages} from '@/components/chatroom/chat-table-messages.js'
import {md} from '@/tools/md.js'
defineProps<{
content: string | null
aiiThinking: string
aiiMessage: string | null
aiiTokenInfo: string
onSendMessage: () => void
onAccept: () => void
onRewrite: () => void
onCancel: () => void
onMessageEdit: (oldMessage: string, newMessage: string, change: 'aii' | 'user') => void
onMessageDelete: (message: string, change: 'aii' | 'user') => void
}>()
const message = defineModel<string>('message', {required: true})
const mode = defineModel<'continue' | 'expand'>('mode', {required: true})
</script>
<template>
<div class="chat-table-container">
<div class="viewer">
<component
v-if="content !== null"
:is="createChatTableMessages(content, onMessageEdit, onMessageDelete)"
/>
<div v-if="aiiMessage !== null" class="user-message message" v-html="md.render(message)"/>
<div v-if="aiiMessage !== null" class="aii-message-streaming message">
<div class="thinking">{{ aiiThinking }}</div>
<div v-html="md.render(aiiMessage)"/>
</div>
</div>
<div v-if="aiiMessage === null" class="editor">
<n-input v-model:value="message" type="textarea" :resizable="false"/>
<n-flex justify="right" align="center">
<n-button type="tertiary" size="small" circle>!</n-button>
<n-switch
v-model:value="mode"
size="large"
checked-value="expand"
unchecked-value="continue"
>
<template #checked>扩写模式</template>
<template #unchecked>推进模式</template>
<template #icon></template>
</n-switch>
<n-button type="primary" size="small" round @click="onSendMessage">发送 </n-button>
</n-flex>
</div>
<div v-else class="confirmer">
<n-flex justify="center" align="center" size="large">
<n-button secondary type="success" size="large" @click="onAccept">接受</n-button>
<n-button secondary type="warning" size="large" @click="onRewrite">重写</n-button>
<n-button secondary type="error" size="large" @click="onCancel">撤回</n-button>
</n-flex>
<p v-html="aiiTokenInfo" style="margin: 0; text-align: center"></p>
</div>
</div>
</template>
<style scoped lang="scss">
div.chat-table-container {
border: 1px solid #434343;
border-radius: 4px;
padding: 3px;
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 3px;
div.viewer {
flex: 1;
overflow: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
div.editor,
div.confirmer {
flex: 0;
min-height: 120px;
border-radius: 4px;
padding: 3px;
}
div.editor {
border: 3px solid rgb(122 255 162 / 0.7);
display: flex;
flex-direction: column;
gap: 3px;
}
div.confirmer {
border: 3px solid rgb(255 110 110 / 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
defineProps<{
id: number
name: string
description: string
feature_image: string
infoMode?: boolean
}>()
</script>
<template>
<div class="chatroom-card">
<n-image v-if="infoMode" class="image" object-fit="cover" preview-disabled :src="feature_image"
width="140"
height="100"/>
<n-image v-else class="image" object-fit="cover" preview-disabled :src="feature_image"
width="84" height="60"/>
<div class="card-body">
<n-text class="name">{{ name }}</n-text>
<n-ellipsis :line-clamp="2" style="max-width: 100%" class="description">
{{ description !== '' ? description : '此聊天室没有任何介绍……神秘的喵!' }}
</n-ellipsis>
</div>
<router-link v-if="infoMode" :to="'/chatroom/' + id">
<div class="button">前往</div>
</router-link>
</div>
</template>
<style scoped lang="scss">
div.chatroom-card {
background: linear-gradient(80deg, hsl(48 100% 85%), hsl(45 100% 94%));
border-radius: 4px;
border: solid 1px #252525;
padding: 4px;
display: flex;
flex-direction: row;
gap: 6px;
.image {
border-radius: 4px;
}
div.card-body {
flex: 1;
padding: 2px;
display: flex;
flex-direction: column;
gap: 3px;
.name {
font-size: 18px;
font-weight: bold;
}
}
.button {
height: 100px;
padding: 0 20px;
border-radius: 4px;
background-color: rgb(94 255 11 / 0.3);
color: #048f01;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import type {ChatroomPublic} from '@/types/chatroom.js'
import {api} from '@/tools/web.js'
import type {ReturnDto} from '@/types/response.js'
import {useMessage} from 'naive-ui'
import UploadFileModal from "@/components/file/UploadFileModal.vue";
import SelectFileModal from "@/components/file/SelectFileModal.vue";
import type {UploadFileDto} from "@/types/user.js";
const MESSAGE = useMessage()
const showModal = defineModel<boolean>('showModal', {required: true})
const showSelectModal = ref(false)
const showUploadModal = ref(false)
const files = ref<UploadFileDto[]>([])
const selectFiles = ref<UploadFileDto[]>([])
const image_url = computed(() => selectFiles.value.at(0)?.download_url)
const createChatroomForm = ref<ChatroomPublic>({
id: 0,
name: '',
description: '',
feature_image: '',
script_template_id: 0,
script_template_version: '',
})
watch(image_url, () => {
if (image_url.value) {
createChatroomForm.value.feature_image = image_url.value
}
})
async function loadFiles() {
return await api.get("/file/").then(res => files.value = res.data as UploadFileDto[])
}
function onSubmit() {
api
.post('/chatroom/', JSON.stringify(createChatroomForm.value))
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
MESSAGE.success('聊天室创建成功 ~')
showModal.value = false
} else {
throw TypeError('未知原因地后端业务失败')
}
})
.catch((err) => {
MESSAGE.error(`聊天室创建失败:${err}`)
})
}
</script>
<template>
<n-modal v-model:show="showModal" preset="card" title="创建聊天室" content-scrollable
style="width: 800px;">
<n-form
:model="createChatroomForm"
label-placement="left"
label-align="right"
label-width="auto"
>
<n-form-item path="name" label="名称">
<n-input v-model:value="createChatroomForm.name"/>
</n-form-item>
<n-form-item path="description" label="简介">
<n-input type="textarea" v-model:value="createChatroomForm.description"/>
</n-form-item>
<n-form-item path="feature_image" label="特色图像">
<n-flex style="width: 100%;" :wrap="false">
<n-input v-model:value="createChatroomForm.feature_image"
placeholder="留空以使用默认图像"/>
<n-button secondary type="info" @click="showSelectModal = true;">选择</n-button>
<n-button secondary type="warning" @click="showUploadModal = true;">上传</n-button>
</n-flex>
</n-form-item>
<n-form-item label="确认?">
<n-button secondary type="primary" @click="onSubmit()">确认</n-button>
</n-form-item>
</n-form>
<select-file-modal :max="1" :extensions="['png', 'jpeg', 'jpg']" :load-files="loadFiles"
v-model:show-modal="showSelectModal" v-model:select-files="selectFiles"/>
<upload-file-modal v-model:show-modal="showUploadModal" :after-leave="loadFiles"/>
</n-modal>
</template>
<style scoped></style>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import {api} from '@/tools/web.js'
import {ref, watch} from 'vue'
import {useRoute} from 'vue-router'
import type {ChatScript} from '@/types/chatroom.js'
import type {ReturnDto} from '@/types/response.js'
import {useMessage} from 'naive-ui'
import XamlModal from '@/components/XamlModal.vue'
const ROUTE = useRoute()
const MESSAGE = useMessage()
const showDrawer = defineModel('showDrawer', {required: true})
const showXamlModal = ref(false)
const {script} = defineProps<{
script: string
}>()
const scriptForm = ref<ChatScript>({
main_prompt: '',
user_prefix: '',
user_suffix: '',
world_books: [],
})
function save() {
const id = Number(ROUTE.params.id)
api
.post(`/chatroom/${id}/script/`, JSON.stringify(scriptForm.value))
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
scriptForm.value = data.result as ChatScript
MESSAGE.success('保存剧本成功~')
} else {
throw TypeError('未知原因后端保存剧本失败。')
}
})
.catch((err) => {
MESSAGE.error(`保存剧本失败:${err}`)
})
}
watch(
() => script,
() => {
try {
scriptForm.value = JSON.parse(script) as ChatScript
} catch {
}
},
{immediate: true},
)
</script>
<template>
<n-drawer
v-model:show="showDrawer"
placement="left"
default-width="50vw"
resizable
native-scrollbar
@after-leave="showDrawer = false"
:z-index="20"
:on-after-leave="
() => {
if (showXamlModal) {
showXamlModal = false
MESSAGE.info('已关闭 Xaml 可视化工具。')
}
}
"
>
<n-drawer-content>
<template #header>
<n-h2 prefix="bar" style="margin-bottom: 0">剧本编辑器</n-h2>
</template>
<n-flex vertical>
<n-alert type="info" title="故事设定 · 世界书">
以下内容会被拼接在提示词以及用户输入中,在向 LLM 发送请求时携带。
</n-alert>
<n-button secondary type="warning" @click="showXamlModal = true">Xaml 可视化</n-button>
<n-card title="全局设定">
<template #header-extra>
<n-tag round type="info">这些内容每一轮请求都会被携带</n-tag>
</template>
<n-form :model="scriptForm">
<n-form-item label="提示词拼接于 Nya 主提示词后" path="main_prompt">
<n-input
type="textarea"
:rows="8"
v-model:value="scriptForm.main_prompt"
show-count
/>
</n-form-item>
<n-form-item label="用户输入前置词拼接于最新一条用户输入前" path="user_prefix">
<n-input
type="textarea"
:rows="8"
v-model:value="scriptForm.user_prefix"
show-count
/>
</n-form-item>
<n-form-item label="用户输入后置词拼接于最新一条用户输入后" path="user_suffix">
<n-input
type="textarea"
:rows="8"
v-model:value="scriptForm.user_suffix"
show-count
/>
</n-form-item>
</n-form>
</n-card>
<n-card title="世界书">
<template #header-extra>
<n-tag round type="info">仅在被提及时才会被动态拼接并携带的细分设定</n-tag>
</template>
</n-card>
</n-flex>
<template #footer>
<n-button type="primary" @click="save()">保存</n-button>
</template>
</n-drawer-content>
<xaml-modal v-model:show-modal="showXamlModal"/>
</n-drawer>
</template>
<style scoped></style>
@@ -0,0 +1,38 @@
import ChatMessage from '@/components/chatroom/ChatMessage.vue'
interface UserMessage {
role: 'user'
message: string
mode: 'continue' | 'expand'
}
interface AiiMessage {
role: 'assistant'
message: string
}
type Message = UserMessage | AiiMessage
type MessageList = Message[]
export function createChatTableMessages(
content: string,
onMessageEdit: (oldMessage: string, newMessage: string, change: 'aii' | 'user') => void,
onMessageDelete: (message: string, change: 'aii' | 'user') => void,
) {
if (!content) return
const content_list: MessageList = JSON.parse(content)
return (
<>
{content_list.map((msg: Message) => {
return (
<ChatMessage
msg={msg.message}
role={msg.role === 'assistant' ? 'aii' : 'user'}
onMessageEdit={onMessageEdit}
onMessageDelete={onMessageDelete}
/>
)
})}
</>
)
}
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type {UploadFileDto} from "@/types/user.js";
import {useNowUser} from "@/stores/now-user.js";
import {computed} from "vue";
const NOWUSER = useNowUser()
const {file} = defineProps<{
file: UploadFileDto
}>()
const is_you = computed(() => NOWUSER.id === file.uploader_id)
const showModal = defineModel("showModal", {required: true})
</script>
<template>
<n-modal v-model:show="showModal" preset="card" style="width: 1000px;" title="文件信息">
<div class="card-content">
<n-image :width="500" :height="500" object-fit="contain" :src="file.download_url"/>
<div class="side">
<n-h3>{{ file.original_name }}</n-h3>
<n-p>保存文件名{{ file.safe_name }}</n-p>
<n-p>上传用户ID{{ file.uploader_id }}
<n-tag v-if="is_you" type="primary"></n-tag>
</n-p>
<n-flex>
<a :href="file.download_url" target="_blank">
<n-button tertiary type="info">永久链接</n-button>
</a>
<n-button tertiary type="error" v-if="is_you">删除文件</n-button>
</n-flex>
</div>
</div>
</n-modal>
</template>
<style scoped lang="scss">
div.card-content {
display: flex;
flex-direction: row;
gap: 16px;
}
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import type {UploadFileDto} from "@/types/user.js";
import {computed, onMounted, ref, useTemplateRef} from "vue";
import FileModal from "@/components/file/FileModal.vue";
const {file, size, enableSelect, onSelect, onRemove} = defineProps<{
file: UploadFileDto
size: number
enableSelect?: boolean
onSelect?: (file: UploadFileDto) => boolean
onRemove?: (file: UploadFileDto) => boolean
}>()
const th = useTemplateRef("th")
const showModal = ref(false)
const selected = ref(false)
const ALLOWED_EXTENSIONS = ["jpg", "jpeg", "png"]
onMounted(() => {
if (ALLOWED_EXTENSIONS.includes(file.safe_name.split('.').at(-1)!.toLowerCase())) {
th.value?.style.setProperty('background-image', 'url("' + file.download_url + '")')
}
})
function onClick() {
if (!enableSelect) {
showModal.value = true
}
if (selected.value && onRemove) {
if (onRemove(file)) {
selected.value = false
th.value?.classList.remove("selected")
console.log(`选中文件:${file.original_name}`)
}
} else if (!selected.value && onSelect) {
if (onSelect(file)) {
selected.value = true
th.value?.classList.add("selected")
console.log(`取消文件:${file.original_name}`)
}
}
}
const size_px = computed(() => `${size}px`)
</script>
<template>
<div class="file-thumbnail" ref="th" @click="onClick"></div>
<file-modal :file v-model:show-modal="showModal"/>
</template>
<style scoped>
div.file-thumbnail {
box-sizing: border-box;
width: v-bind(size_px);
height: v-bind(size_px);
border-radius: 3px;
border: 2px solid rgb(0 0 0 / 0.2);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
transition: border-color 0.3s, box-shadow 0.3s;
}
div.selected {
border-color: rgb(74 228 112 / 0.8);
box-shadow: 0 0 0 2px rgb(104 104 104 / 0.2);
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import {selectFilesCom} from "@/components/file/upload-files.js";
import {computed, ref, watch} from "vue";
import type {UploadFileDto} from "@/types/user.js";
import {useMessage} from "naive-ui";
const MESSAGE = useMessage()
const {max, extensions, loadFiles} = defineProps<{
max: number
extensions: string[]
loadFiles: () => Promise<UploadFileDto[]>
}>()
const showModal = defineModel("showModal", {required: true})
const files = ref<UploadFileDto[]>([])
const tempFiles = ref<UploadFileDto[]>([])
const selectFiles = defineModel<UploadFileDto[]>("selectFiles", {required: true})
function selectFile(file: UploadFileDto) {
if (tempFiles.value.length < max) {
tempFiles.value.push(file)
return true
} else {
MESSAGE.warning("可选择文件数量达到上限……")
return false
}
}
function removeFile(file: UploadFileDto) {
const i = tempFiles.value.findIndex((item) => item.id === file.id)
if (i >= 0) {
tempFiles.value.splice(i, 1)
}
return true
}
watch(showModal, async () => {
tempFiles.value = [] // 每次打开模态框时都重置已选文件
files.value = await loadFiles()
})
const tip_1 = computed(() => max > 1 ? `请选择至少 ${max} 个文件。` : "请选择一个文件。")
const tip_2 = computed(() => `允许的文件类型:${extensions.join('、')}`)
</script>
<template>
<n-modal preset="card" style="max-width: 600px; max-height: 600px;" title="选择文件"
content-scrollable
v-model:show="showModal">
<n-flex vertical>
<n-alert type="info">
{{ tip_1 }}
{{ tip_2 }}
</n-alert>
<component :is="selectFilesCom(files, selectFile, removeFile)"/>
<n-button type="primary" secondary @click="selectFiles = tempFiles; showModal = false;">
确认选择
</n-button>
</n-flex>
</n-modal>
</template>
<style scoped>
</style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import {type UploadCustomRequestOptions, type UploadFileInfo} from "naive-ui";
import {api} from "@/tools/web.js";
import type {UploadFileDto} from "@/types/user.js";
import {shallowRef, useTemplateRef} from "vue";
defineProps<{
afterLeave?: () => void;
}>()
const showModal = defineModel("showModal", {required: true})
const upload = useTemplateRef("upload")
const fileList = shallowRef<UploadFileInfo[]>([])
async function handle_upload({file, onFinish, onError, onProgress}: UploadCustomRequestOptions) {
const formData = new FormData();
console.log(file.file)
formData.append("file", file.file!)
console.log(formData)
try {
const data = await api.post("/file/upload/", formData, {
headers: {
'Content-Type': undefined // 取消全局默认的 application/json 很重要!!!!!!!!
},
onUploadProgress: (progressEvent) => {
const percent = Math.ceil(
(progressEvent.loaded / progressEvent.total!) * 100
)
onProgress({percent}) // 更新进度条
}
}).then((res) => res.data as UploadFileDto)
file.url = data.download_url
onFinish()
} catch (err) {
console.error(`文件上传失败:${err}`)
onError()
}
}
function onUpload() {
upload.value?.submit()
}
</script>
<template>
<n-modal style="width: 600px;" preset="card" v-model:show="showModal"
title="上传文件" content-scrollable
@after-leave="afterLeave">
<n-flex vertical>
<n-upload multiple ref="upload" :default-upload="false" list-type="image"
:custom-request="handle_upload" v-model:file-list="fileList">
<n-upload-dragger>
<n-p>拖拽文件到此区域可以快速上传</n-p>
</n-upload-dragger>
</n-upload>
<n-flex>
<n-button type="primary" secondary @click="onUpload">上传</n-button>
<n-tag size="large" type="info">如有必要请在上传前在您的本地对文件进行重命名~</n-tag>
</n-flex>
</n-flex>
</n-modal>
</template>
<style scoped></style>
@@ -0,0 +1,30 @@
import type {UploadFileDto} from "@/types/user.ts";
import FileThumbnail from "@/components/file/FileThumbnail.vue";
import {NEmpty, NFlex} from "naive-ui";
export function uploadFilesCom(files: UploadFileDto[]) {
if (files.length === 0) {
return <NEmpty description="你还没有上传任何文件。" size="large"/>
}
return <NFlex>
{files.map((file: UploadFileDto) => {
return <FileThumbnail size={120} file={file}></FileThumbnail>;
})}
</NFlex>
}
export function selectFilesCom(
files: UploadFileDto[],
onSelect: (file: UploadFileDto) => boolean,
onRemove: (file: UploadFileDto) => boolean
) {
if (files.length === 0) {
return <NEmpty description="你还没有上传任何文件。" size="large"/>
}
return <NFlex>
{files.map((file: UploadFileDto) => {
return <FileThumbnail size={82} file={file} enableSelect onSelect={onSelect}
onRemove={onRemove}></FileThumbnail>;
})}
</NFlex>
}
+31
View File
@@ -0,0 +1,31 @@
import { NAlert, NH4, NP } from 'naive-ui'
export interface Xaml {
name: string
message: string
subXamls: Xaml[]
}
export function createXamlBlock(xamls: Xaml[]) {
return (
<>
{xamls.map((xaml) => {
return (
<div class="xaml-block">
<NH4 class="title">{xaml.name}</NH4>
<NP class="text">{xaml.message}</NP>
{createXamlBlock(xaml.subXamls)}
</div>
)
})}
</>
)
}
export function createErrorBlock(msg: string) {
return (
<NAlert type="error" title="可视化失败……">
{msg}
</NAlert>
)
}
+5
View File
@@ -1,14 +1,19 @@
import {createApp} from 'vue'
import {createPinia} from 'pinia'
import {createHead} from "@unhead/vue/client";
import '@/assets/main.scss'
import '@/assets/beautiful.scss'
import '@/assets/chat.scss'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const head = createHead()
app.use(createPinia())
app.use(router)
app.use(head)
app.mount('#app')
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import UserAction from "@/components/admin/UserAction.vue";
import type {MenuOption} from "naive-ui";
import {computed, onMounted, ref, useTemplateRef} from "vue";
import {useRouter} from "vue-router";
import {useNowUser} from "@/stores/now-user.js";
import {useHead} from "@unhead/vue";
useHead({
titleTemplate: "%s | 管理面板 | NayHome"
})
const ROUTER = useRouter()
const NOWUSER = useNowUser()
const menu = useTemplateRef("menu")
const selectOption = ref("")
const options = computed<MenuOption[]>(() => [
{
label: "总览",
key: "",
},
{
label: "用户",
key: "user-basic",
children: [
{
label: "资料",
key: "user-info"
},
{
label: "安全",
key: "user-security"
}
]
},
{
label: "内容",
key: "user-creation",
children: [
{
label: "上传",
key: "user-upload"
},
{
label: "剧本",
key: "user-script"
}
]
},
{
label: "NyaHome 管理后台",
key: "nyahome",
show: NOWUSER.is_admin,
}
])
function handleMenuClick(key: string) {
ROUTER.push(`/admin/${key}`)
}
onMounted(() => {
const key = ROUTER.currentRoute.value.fullPath.replace("/admin/", "")
if (key) {
selectOption.value = key
menu.value?.showOption(key)
} else {
selectOption.value = ""
}
})
</script>
<template>
<div id="user-page">
<div id="user-page-sidebar">
<user-action/>
<div class="nyahome-card">
<n-menu ref="menu" v-model:value="selectOption" :options @update:value="handleMenuClick"/>
</div>
</div>
<router-view v-slot="{Component}">
<div id="user-page-content">
<keep-alive>
<component :is="Component"/>
</keep-alive>
</div>
</router-view>
</div>
</template>
<style scoped lang="scss">
div#user-page {
min-width: min(1200px, 90%);
width: min(1200px, 90%);
min-height: 0;
padding: 6px 20px;
display: flex;
flex-direction: row;
gap: 10px;
div#user-page-sidebar {
flex: 0;
flex-basis: 350px;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
div#user-page-content {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
}
</style>
+364
View File
@@ -0,0 +1,364 @@
<script setup lang="ts">
import {useRoute} from 'vue-router'
import {onMounted, ref, useTemplateRef, watch} from 'vue'
import {api} from '@/tools/web.ts'
import type {ReturnDto} from '@/types/response.ts'
import type {Chatroom} from '@/types/chatroom.ts'
import {useMessage} from 'naive-ui'
import ChatroomCard from '@/components/chatroom/ChatroomCard.vue'
import ChatTable from '@/components/chatroom/ChatTable.vue'
import ChatControlPanel from '@/components/chatroom/ChatControlPanel.vue'
import {fetchEventSource} from '@microsoft/fetch-event-source'
import type {AiiTokenInfo} from '@/types/aii.ts'
import {SEE_YOU_TOMORROW} from '@/types/syt.ts'
const ROUTE = useRoute()
const MESSAGE = useMessage()
const crName = ref('')
const crDescription = ref('')
const crFeatureImage = ref('')
const crContent = ref('')
const crScript = ref('')
const selectedModel = ref<number | null>(null)
const quickerPrompt = ref('')
const inputMessage = ref<string>('')
const inputMode = ref<'continue' | 'expand'>('expand')
// aiiMessage 是 AI **正在** 输出时的存储 AI 输出的容器。在 AI 完成输出后、开始输出前以及错误被处理后,应该为 null。
const aiiMessage = ref<string | null>(null)
// aiiThinking 是思维链/思考过程,有的模型不提供
const aiiThinking = ref('')
const aiiTokenInfo = ref(SEE_YOU_TOMORROW)
function load() {
const id = Number(ROUTE.params.id)
api
.get(`/chatroom/${id}/`)
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
return data.result as Chatroom
} else {
throw TypeError('聊天室不存在,请检查。')
}
})
.then((cr) => {
crName.value = cr.name
crDescription.value = cr.description
crFeatureImage.value = cr.feature_image
crContent.value = cr.content
crScript.value = cr.script
})
.catch((e) => {
MESSAGE.error(`访问聊天室失败:${e}`)
})
}
watch(
() => ROUTE.params.id,
(newId, oldId) => {
console.log(`聊天室跳转,从 ${oldId} 来到 ${newId}`)
load()
},
)
function chat() {
if (!selectedModel.value) {
MESSAGE.warning('未选择模型,无法开始创作喵!')
return
}
if (inputMessage.value === '') {
MESSAGE.warning('未输入任何内容,无法开始创作喵!')
return
}
const id = Number(ROUTE.params.id)
aiiThinking.value = ''
aiiMessage.value = ''
aiiTokenInfo.value = SEE_YOU_TOMORROW
// /chatroom/${id}/chat 接口返回的是 SSE 流式输出
fetchEventSource(`/api/chatroom/${id}/chat/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: inputMessage.value,
prefix: quickerPrompt.value,
mode: inputMode.value,
model_id: selectedModel.value,
}),
openWhenHidden: true, // 此开关控制在浏览器失去焦点时是否保持连接开启。默认为 false 会导致焦点转移时流式传输中断然后重连,很怪
onmessage(msg) {
if (msg.data === '[DONE]') {
MESSAGE.success('AI 似乎已经完成创作,请检查一下喵!')
console.log('SSE 流式输出结束。')
// aiiMessage.value = null // 即使 AI 完成输出,也等待用户确认后再保存喵!
return
}
const data: { type: 'output' | 'thinking' | 'usage'; text?: string } = JSON.parse(msg.data)
if (data.type === 'output') {
aiiMessage.value += data.text as string
} else if (data.type === 'thinking') {
aiiThinking.value += data.text as string
} else {
const usage = data as AiiTokenInfo
aiiTokenInfo.value = `总计:${usage.total_tokens} | 输入:${usage.prompt_tokens} | 输出:${usage.completion_tokens}`
if (usage.prompt_cache_hit_tokens && usage.prompt_cache_miss_tokens) {
aiiTokenInfo.value += `<br />[ 输入(缓存):${usage.prompt_cache_hit_tokens} | 输入(未缓存):${usage.prompt_cache_miss_tokens} ]`
}
}
},
onerror(err) {
console.error(`SSE 错误:${err}`)
// aiiMessage.value = null // 等待用户主动确认 AI 输出错误之后,再主动重置为 null
throw err
},
})
}
function accept() {
const id = Number(ROUTE.params.id)
api
.post(
`/chatroom/${id}/chat/accept/`,
JSON.stringify({
aii_message: aiiMessage.value,
user_message: inputMessage.value,
mode: inputMode.value,
}),
)
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
MESSAGE.success('保存成功,正在刷新创作视图喵~')
aiiMessage.value = null
inputMessage.value = ''
return data.result as Chatroom
} else {
throw TypeError('未知原因,后端业务错误')
}
})
.then((result) => {
crContent.value = result.content
})
.catch((err) => {
MESSAGE.error(`保存失败:${err}`)
})
}
function rewrite() {
chat()
}
function cancel() {
aiiMessage.value = null
}
function messageEdit(oldMessage: string, newMessage: string, change: 'aii' | 'user') {
const id = Number(ROUTE.params.id)
api
.post(
`/chatroom/${id}/chat/edit/`,
JSON.stringify({
old_message: oldMessage,
new_message: newMessage,
change,
}),
)
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
return data.result as Chatroom
} else {
throw TypeError('未知原因,后端聊天记录修改失败')
}
})
.then((result) => {
crContent.value = result.content
MESSAGE.success('聊天记录已删除,页面已更新~')
})
.catch((err) => {
MESSAGE.error(`修改聊天消息失败:${err}`)
})
}
function messageDelete(message: string, change: 'aii' | 'user') {
const id = Number(ROUTE.params.id)
api
.post(`/chatroom/${id}/chat/delete/`, JSON.stringify({message, change}))
.then((res) => res.data as ReturnDto)
.then((data) => {
if (data.success) {
return data.result as Chatroom
} else {
throw TypeError('未知原因,后端聊天记录删除失败')
}
})
.then((result) => {
crContent.value = result.content
MESSAGE.success('聊天记录已删除,页面已更新~')
})
.catch((err) => {
MESSAGE.error(`删除聊天消息失败:${err}`)
})
}
onMounted(() => {
enableSidebar()
load()
})
const mainToggle = useTemplateRef('main-toggle')
const sidebar = useTemplateRef('sidebar')
function disableSidebar() {
mainToggle.value?.style.setProperty('--opacity', '1')
sidebar.value?.style.setProperty('--width', '0')
sidebar.value?.style.setProperty('--opacity', '0')
sidebar.value?.style.setProperty('--transform-x', '100%')
}
function enableSidebar() {
mainToggle.value?.style.setProperty('--opacity', '0')
sidebar.value?.style.setProperty('--width', '400px')
sidebar.value?.style.setProperty('--opacity', '1')
sidebar.value?.style.setProperty('--transform-x', '0')
}
</script>
<template>
<div class="page-container">
<div class="main-column">
<chatroom-card :id="Number(ROUTE.params.id)" :name="crName" :description="crDescription"
:feature_image="crFeatureImage"/>
<chat-table
:content="crContent"
:aii-thinking
:aii-message
:aii-token-info
v-model:message="inputMessage"
v-model:mode="inputMode"
:on-send-message="chat"
:on-accept="accept"
:on-rewrite="rewrite"
:on-cancel="cancel"
:on-message-edit="messageEdit"
:on-message-delete="messageDelete"
/>
<div id="main-toggle" ref="main-toggle" @click="enableSidebar"/>
</div>
<div class="sidebar-column" ref="sidebar">
<chat-control-panel
:script="crScript"
v-model:quicker-prompt="quickerPrompt"
v-model:select-model="selectedModel"
/>
<div id="sidebar-toggle" @click="disableSidebar"/>
</div>
</div>
</template>
<style scoped lang="scss">
div.page-container {
width: min(1200px, 90vw);
display: flex;
flex-direction: row;
height: 100%;
overflow: hidden;
div.main-column {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
div#main-toggle {
--opacity: 1;
position: absolute;
top: 40%;
right: 12px;
height: 20%;
width: 10px;
background: rgb(0 0 0 / 0.1);
border-radius: 5px;
opacity: var(--opacity);
transition: background-color 0.8s,
transform 0.5s,
opacity 1s,
height 0.5s,
width 0.5s,
top 0.5s,
border-radius 0.5s;
&:hover {
background: rgb(0 0 0 / 0.6);
transform: translateX(-4px);
top: calc(50% - 15px);
height: 30px;
width: 30px;
border-radius: 15px;
}
}
}
div.sidebar-column {
--transition-x: 100%;
--opacity: 1;
--width: 400px;
flex: 0;
overflow: auto;
position: relative;
transition: transform 1s,
opacity 1s,
flex-basis 1s;
transform: translateX(var(--transform-x));
flex-basis: var(--width);
opacity: var(--opacity);
div#sidebar-toggle {
position: absolute;
top: 40%;
left: 0;
height: 20%;
width: 10px;
background: rgb(0 0 0 / 0.1);
border-radius: 5px;
transition: background-color 0.8s,
transform 0.5s,
height 0.5s,
width 0.5s,
top 0.5s,
border-radius 0.5s;
&:hover {
background: rgb(0 0 0 / 0.6);
transform: translateX(4px);
top: calc(50% - 15px);
height: 30px;
width: 30px;
border-radius: 15px;
}
}
}
}
</style>
+63 -3
View File
@@ -1,5 +1,65 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import ChatroomCard from '@/components/chatroom/ChatroomCard.vue'
import {ref, watch} from 'vue'
import {api} from '@/tools/web.ts'
import type {ChatroomPublic} from '@/types/chatroom.ts'
import type {ReturnDto} from '@/types/response.ts'
import ChatroomCreatorModal from '@/components/chatroom/ChatroomCreatorModal.vue'
import {useNowUser} from "@/stores/now-user.ts";
<template></template>
const NOWUSER = useNowUser()
<style scoped></style>
const crList = ref<ChatroomPublic[]>([])
const showModal = ref(false)
function load() {
api
.get('/chatroom/')
.then((res) => res.data as ReturnDto)
.then((data) => data.result as ChatroomPublic[])
.then((list) => {
crList.value = list
})
}
watch(() => NOWUSER.isLogin, () => {
load()
}, {immediate: true})
</script>
<template>
<n-card title="聊天室">
查看您创建的所有聊天室
<template #footer>
<n-flex>
<n-button secondary type="primary" style="margin-left: auto" @click="showModal = true">
创建聊天室
</n-button>
</n-flex>
</template>
</n-card>
<div id="chatroom-card-list">
<chatroom-card
v-for="cr in crList"
v-bind:key="cr.id"
:id="cr.id"
:name="cr.name"
:description="cr.description"
:feature_image="cr.feature_image"
info-mode
/>
</div>
<chatroom-creator-modal v-model:show-modal="showModal"/>
</template>
<style scoped>
div#chatroom-card-list {
width: 800px;
display: flex;
flex-direction: column;
gap: 6px;
}
</style>
+21 -3
View File
@@ -1,7 +1,25 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import UserAction from '@/components/admin/UserAction.vue'
</script>
<template>
<n-card title="Welcome to Welcome!"></n-card>
<n-flex vertical style="padding: 6px 20px">
<n-flex>
<n-card class="welcome-card" title="Welcome to Welcome!"></n-card>
<div class="user-action-card">
<user-action/>
</div>
</n-flex>
</n-flex>
</template>
<style scoped></style>
<style scoped>
.welcome-card {
flex: 1;
}
div.user-action-card {
flex: 0;
flex-basis: 350px;
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import ConfigCard from "@/components/admin/ConfigCard.vue";
import {useHead} from "@unhead/vue";
import {ref} from "vue";
import {api} from "@/tools/web.ts";
import InDev from "@/components/InDev.vue";
import {useMessage} from "naive-ui";
import type {ReturnDto} from "@/types/response.ts";
interface SiteConfig {
site_name: string;
site_url: string;
backend_url: string;
jwt_secret_key: string;
smtp_enable: boolean;
smtp_sender: string;
smtp_hostname: string;
smtp_port: number;
smtp_username: string;
smtp_password: string;
smtp_use_tls: boolean;
}
const MESSAGE = useMessage()
useHead({
title: "NyaHome 管理后台"
})
const siteConfig = ref<SiteConfig | null>(null);
function getConfig() {
api.get("/admin/site_config/")
.then((res) => {
siteConfig.value = res.data as SiteConfig
MESSAGE.success("成功获取设置~")
})
}
function saveConfig() {
api.post("/admin/site_config/", JSON.stringify(siteConfig.value))
.then((res) => {
siteConfig.value = res.data as SiteConfig
MESSAGE.success("保存并刷新设置成功~")
})
}
const testMailTo = ref("25565@qq.com")
function sendTestMail() {
api.post("/admin/email-test/", JSON.stringify({to: testMailTo.value}))
.then(res => res.data as ReturnDto)
.then(data => data.success)
.then(success => {
if (success) {
MESSAGE.success("邮件发送成功,请稍等片刻,然后检查收件箱~")
} else {
MESSAGE.error("后端表示邮件发送失败,请检查日志输出。")
}
})
}
</script>
<template>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0;">NyaHome 管理后台</n-h3>
</template>
<template #header-extra>
<n-flex>
<n-button type="success" secondary @click="getConfig()">获取设置</n-button>
<n-button type="error" secondary @click="saveConfig()">保存设置</n-button>
</n-flex>
</template>
</n-card>
<n-tabs type="card" v-if="siteConfig !== null">
<n-tab-pane name="user" tab="用户" display-directive="show">
<config-card title="全部用户">
<in-dev/>
</config-card>
</n-tab-pane>
<n-tab-pane name="chatroom" tab="聊天室" display-directive="show">
<config-card title="全部聊天室">
<in-dev/>
</config-card>
</n-tab-pane>
<n-tab-pane name="script" tab="剧本" display-directive="show">
<config-card title="全部剧本">
<in-dev/>
</config-card>
</n-tab-pane>
<n-tab-pane name="site_info" tab="站点信息" display-directive="show">
<n-flex vertical>
<config-card title="基本信息">
<n-form>
<n-form-item label="站点名称">
<n-input v-model:value="siteConfig.site_name"/>
</n-form-item>
<n-form-item label="站点地址">
<n-input v-model:value="siteConfig.site_url"/>
</n-form-item>
<n-alert type="info" class="in-form-alert">
如果您需要将 NyaHome 的前后端分开部署则需要在此设置后端地址您需要自行处理跨域问题
</n-alert>
<n-form-item label="FastAPI 后端地址">
<n-input v-model:value="siteConfig.backend_url"/>
</n-form-item>
</n-form>
</config-card>
<config-card title="搜索引擎设置与 SEO">
<in-dev/>
</config-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="permission" tab="权限设置" display-directive="show">
<config-card title="用户权限">
<in-dev/>
</config-card>
</n-tab-pane>
<n-tab-pane name="security" tab="安全设置" display-directive="show">
<n-flex vertical>
<config-card title="JWT">
<n-form>
<n-alert type="info" class="in-form-alert">
JWTJson Web Token签名需要一个密钥你可以手动提供一个或者自行生成一个<br/>
修改此密钥会导致所有用户的登录状态丢失你也会请一次性设置一个足够安全的
</n-alert>
<n-form-item label="JWT 密钥">
<n-input v-model:value="siteConfig.jwt_secret_key"/>
</n-form-item>
</n-form>
</config-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="remote_service" tab="外部服务" display-directive="show">
<n-flex vertical>
<config-card title="邮件 SMTP">
<n-form>
<n-alert type="info" class="in-form-alert">
NayHome 无法自己发送邮件需要配置 SMTP 服务<br/>
或者你也可以关闭邮件功能当然芒果还是建议你配置一下的
</n-alert>
<n-form-item label="启用邮件功能(SMTP">
<n-switch v-model:value="siteConfig.smtp_enable"/>
</n-form-item>
<n-form-item label="发件人邮件地址">
<n-input v-model:value="siteConfig.smtp_sender"/>
</n-form-item>
<n-form-item label="SMTP 主机名">
<n-input v-model:value="siteConfig.smtp_hostname"/>
</n-form-item>
<n-form-item label="SMTP 端口">
<n-input-number v-model:value="siteConfig.smtp_port"/>
</n-form-item>
<n-form-item label="SMTP 用户名">
<n-input v-model:value="siteConfig.smtp_username"/>
</n-form-item>
<n-form-item label="SMTP 密码(一般应当是一个独立的应用程序密码)">
<n-input v-model:value="siteConfig.smtp_password"/>
</n-form-item>
<n-form-item label="使用 TLS/SSL 加密">
<n-switch v-model:value="siteConfig.smtp_use_tls"/>
</n-form-item>
</n-form>
<template #action>
<n-flex vertical>
<n-text>你可以在这里测试 NayHome 的邮件系统能否使用上述 SMTP
设置工作这会发送一封测试邮件
</n-text>
<n-input v-model:value="testMailTo"/>
<n-button secondary type="warning" @click="sendTestMail()">发送测试邮件</n-button>
</n-flex>
</template>
</config-card>
</n-flex>
</n-tab-pane>
</n-tabs>
<n-empty size="large" v-else description="请尝试手动获取设置..."/>
</template>
<style scoped>
.in-form-alert {
margin-bottom: 16px;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import {useNowUser} from "@/stores/now-user.js";
import {computed} from "vue";
import {useHead} from "@unhead/vue";
useHead({
title: "总览"
})
const NOWUSER = useNowUser()
const backgroundUrl = computed(() => `url("${NOWUSER.background_url}")`)
</script>
<template>
<div class="overview"></div>
</template>
<style scoped>
div.overview {
width: 100%;
height: 300px;
border-radius: 6px;
background-color: #ddfbff;
background-image: v-bind(backgroundUrl);
background-size: cover;
background-position: top;
background-repeat: no-repeat;
}
</style>
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { useNowUser } from '@/stores/now-user.js'
import { ref, watch } from 'vue'
import SelectFileModal from '@/components/file/SelectFileModal.vue'
import { api } from '@/tools/web.js'
import type { UploadFileDto, UserDto } from '@/types/user.js'
import { useHead } from '@unhead/vue'
import ChangeEmailModal from '@/components/admin/ChangeEmailModal.vue'
useHead({
title: '用户资料',
})
const NOWUSER = useNowUser()
const showAvatarModal = ref(false)
const showBackgroundModal = ref(false)
const files = ref<UploadFileDto[]>([])
const avatar_selectFiles = ref<UploadFileDto[]>([])
const background_selectFiles = ref<UploadFileDto[]>([])
const showChangeEmailModal = ref(false)
const showChangePhoneModal = ref(false)
async function loadFiles() {
return await api.get('/file/').then((res) => (files.value = res.data as UploadFileDto[]))
}
const infoForm = ref({
name: '',
display_name: '',
avatar_url: '',
background_url: '',
description: '',
})
function reInitForm() {
infoForm.value.name = NOWUSER.name
infoForm.value.display_name = NOWUSER.display_name
infoForm.value.avatar_url = NOWUSER.avatar_url
infoForm.value.background_url = NOWUSER.background_url
infoForm.value.description = NOWUSER.description
}
watch(
() => avatar_selectFiles.value.at(0)?.download_url,
(value) => {
if (value) {
infoForm.value.avatar_url = value
} else {
infoForm.value.avatar_url = NOWUSER.avatar_url
}
},
)
watch(
() => background_selectFiles.value.at(0)?.download_url,
(value) => {
if (value) {
infoForm.value.background_url = value
} else {
infoForm.value.background_url = NOWUSER.background_url
}
},
)
watch(
() => NOWUSER.isLogin,
() => {
reInitForm()
},
{ immediate: true },
)
async function save() {
const user = await api
.post('/admin/me/', JSON.stringify(infoForm.value))
.then((res) => res.data as UserDto)
await NOWUSER.loadWithUserInfo(user)
}
</script>
<template>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0">用户资料</n-h3>
</template>
<n-alert type="warning">
您需要通过用户名邮箱和手机号三者之一进行登录修改之后请牢记新的用户名
</n-alert>
<div class="ui-content">
<n-form style="width: 450px" label-width="auto" label-placement="left" label-align="right">
<n-form-item label="用户名">
<n-input v-model:value="infoForm.name" />
</n-form-item>
<n-form-item label="展示名称">
<n-input v-model:value="infoForm.display_name" />
</n-form-item>
<n-form-item label="头像">
<n-flex>
<n-avatar v-model:src="infoForm.avatar_url" :size="96" circle />
<n-flex vertical>
<n-tag type="info">需在内容-上传中提前上传图像</n-tag>
<n-tag type="warning">使用方形图像以获得最佳效果</n-tag>
<n-flex>
<n-button secondary type="info" @click="showAvatarModal = true">选择</n-button>
<n-button
secondary
type="tertiary"
@click="infoForm.avatar_url = NOWUSER.avatar_url"
>重置
</n-button>
</n-flex>
</n-flex>
</n-flex>
</n-form-item>
<n-form-item label="个人背景">
<n-flex>
<n-avatar v-model:src="infoForm.background_url" :size="96" object-fit="cover" />
<n-flex vertical>
<n-tag type="info">需在内容-上传中提前上传图像</n-tag>
<n-flex>
<n-button secondary type="info" @click="showBackgroundModal = true">选择</n-button>
<n-button
secondary
type="tertiary"
@click="infoForm.background_url = NOWUSER.background_url"
>重置
</n-button>
</n-flex>
</n-flex>
</n-flex>
</n-form-item>
<n-form-item label="个人介绍">
<n-input
v-model:value="infoForm.description"
:autosize="{ minRows: 2, maxRows: 5 }"
type="textarea"
/>
</n-form-item>
<n-form-item label="邮箱">
<n-input v-model:value="NOWUSER.email" disabled />
</n-form-item>
<n-form-item label="手机号">
<n-input v-model:value="NOWUSER.phone" disabled />
</n-form-item>
</n-form>
<n-flex>
<n-button class="ui-button" type="primary" @click="save">保存</n-button>
<n-button class="ui-button" type="warning" @click="showChangeEmailModal = true"
>更改邮箱</n-button
>
<n-button class="ui-button" type="warning">更改手机号</n-button>
<n-button class="ui-button" type="tertiary">重置全部</n-button>
</n-flex>
</div>
<select-file-modal
:load-files="loadFiles"
:max="1"
:extensions="['png', 'jpg', 'jpeg']"
v-model:show-modal="showAvatarModal"
v-model:select-files="avatar_selectFiles"
/>
<select-file-modal
:load-files="loadFiles"
:max="1"
:extensions="['png', 'jpg', 'jpeg']"
v-model:show-modal="showBackgroundModal"
v-model:select-files="background_selectFiles"
/>
<change-email-modal v-model:show-modal="showChangeEmailModal" />
</n-card>
</template>
<style scoped>
div.ui-content {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.ui-button {
flex-basis: 100px;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import InDev from "@/components/InDev.vue";
import {useHead} from "@unhead/vue";
useHead({
title: "剧本"
})
</script>
<template>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0;">个人剧本库</n-h3>
</template>
<in-dev/>
</n-card>
</template>
<style scoped></style>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import {useNowUser} from "@/stores/now-user.js";
import UserPasswordModal from "@/components/admin/UserPasswordModal.vue";
import {h, ref} from "vue";
import {api} from "@/tools/web.ts";
import {type DataTableColumn, NTag, NText} from "naive-ui";
import InDev from "@/components/InDev.vue";
import {useHead} from "@unhead/vue";
useHead({
title: "用户安全"
})
const NOWUSER = useNowUser()
const showPasswordModal = ref(false)
interface SecureChange {
created_at: number;
type: "login" | "change_password" | "change_email" | "change_phone"
old: string | null
new: string | null
}
const secureChanges = ref<SecureChange[]>([])
const columns = [
{
title: "记录时间",
key: "created_at",
render(row) {
const date = new Date(row.created_at * 1000)
return h(
NText,
{},
{default: () => date.toLocaleString()}
)
}
},
{
title: "类型",
key: "type",
render: (row) => {
return h(
NTag,
{
type: "info"
},
{default: () => row.type}
)
}
},
{
title: "事件之前",
key: "old",
},
{
title: "事件之后",
key: "new",
}
] as DataTableColumn<SecureChange>[]
function loadSecureChanges() {
api.get("/admin/me/secure_changes/")
.then(res => secureChanges.value = res.data as SecureChange[])
}
</script>
<template>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0;">密码</n-h3>
</template>
<n-flex vertical>
<n-alert type="warning" v-if="NOWUSER.id === 1">
您正在使用 NyaHome 初始化时创建的管理员账号此账号的默认密码为 admin
<strong>您应该及时修改默认密码</strong><br/>
如果您已修改密码请忽略
</n-alert>
<n-flex>
<n-button type="error" @click="showPasswordModal = true">修改密码</n-button>
<n-button type="warning">忘记密码</n-button>
</n-flex>
</n-flex>
<user-password-modal v-model:show-modal="showPasswordModal"/>
</n-card>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0;">其他登录方式</n-h3>
</template>
<n-flex vertical>
<n-alert type="info">
在这里连接第三方账户之后可以使用它们进行登录<br/>
<strong>必须先在这里连接后才能使用第三方账户进行登录</strong>
</n-alert>
<in-dev/>
</n-flex>
</n-card>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0;">两步验证</n-h3>
</template>
<n-flex vertical>
<n-alert type="info">
启用两步验证可以更好地保护您的账户这会强制此账号在登录时进行额外验证
</n-alert>
<in-dev/>
</n-flex>
</n-card>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0;">安全事件记录</n-h3>
</template>
<n-flex vertical>
<n-data-table :columns :data="secureChanges"/>
<n-button secondary type="warning" @click="loadSecureChanges()">查询更新</n-button>
</n-flex>
</n-card>
</template>
<style scoped>
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import {ref, watch} from "vue";
import UploadFileModal from "@/components/file/UploadFileModal.vue";
import {api} from "@/tools/web.js";
import type {UploadFileDto} from "@/types/user.js";
import {useNowUser} from "@/stores/now-user.js";
import {uploadFilesCom} from "@/components/file/upload-files.js";
import {useHead} from "@unhead/vue";
useHead({
title: "上传"
})
const NOWUSER = useNowUser();
const showUploadModal = ref(false)
const files = ref<UploadFileDto[]>([])
function load() {
api.get("/file/")
.then(res => {
files.value = res.data as UploadFileDto[]
})
}
watch(() => NOWUSER.isLogin, () => {
load()
}, {immediate: true})
</script>
<template>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0;">上传文件</n-h3>
</template>
<n-flex vertical>
<n-alert type="info">
接受的文件类型
</n-alert>
<n-button @click="showUploadModal = true">打开上传向导</n-button>
<upload-file-modal v-model:show-modal="showUploadModal" :after-leave="() => {load()}"/>
</n-flex>
</n-card>
<n-card>
<template #header>
<n-h3 prefix="bar" style="margin: 0;">个人文件库</n-h3>
</template>
<template #header-extra>
您已经上传的文件都在这里,可以选择性地删除以及重新下载。
</template>
<component :is="uploadFilesCom(files)"/>
</n-card>
</template>
<style scoped>
</style>
+50
View File
@@ -1,6 +1,14 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import ChatroomPage from '@/pages/ChatroomPage.vue'
import WelcomePage from '@/pages/WelcomePage.vue'
import Chatroom1Page from "@/pages/Chatroom1Page.vue";
import AdminPage from "@/pages/AdminPage.vue";
import AdminOverview from "@/pages/admin/AdminOverview.vue";
import AdminUserInfo from "@/pages/admin/AdminUserInfo.vue";
import AdminUserSecurity from "@/pages/admin/AdminUserSecurity.vue";
import AdminUserUpload from "@/pages/admin/AdminUserUpload.vue";
import AdminNyahome from "@/pages/admin/AdminNyahome.vue";
import AdminUserScript from "@/pages/admin/AdminUserScript.vue";
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
@@ -10,11 +18,53 @@ const router = createRouter({
path: '/',
component: WelcomePage,
},
{
name: 'chatroom-1',
path: '/chatroom/:id',
component: Chatroom1Page,
},
{
name: 'chatroom',
path: '/chatroom',
component: ChatroomPage,
},
{
name: 'admin',
path: '/admin/',
component: AdminPage,
children: [
{
name: "admin-overview",
path: "",
component: AdminOverview,
},
{
name: "admin-user-info",
path: "user-info",
component: AdminUserInfo,
},
{
name: "admin-user-security",
path: "user-security",
component: AdminUserSecurity,
},
{
name: "admin-user-upload",
path: "user-upload",
component: AdminUserUpload,
},
{
name: "admin-user-script",
path: "user-script",
component: AdminUserScript,
},
{
name: "admin-nyahome",
path: "nyahome",
component: AdminNyahome,
}
]
},
],
})
-12
View File
@@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
+63
View File
@@ -0,0 +1,63 @@
import {defineStore} from 'pinia'
import {ref} from 'vue'
import {api, setApiToken} from '@/tools/web.ts'
import type {UserDto} from '@/types/user.ts'
export const useNowUser = defineStore('now-user', () => {
const isLogin = ref(false)
const id = ref(0)
const name = ref('')
const display_name = ref('')
const email = ref('')
const phone = ref('')
const avatar_url = ref('')
const background_url = ref('')
const description = ref('')
const is_admin = ref(false)
async function loadUserInfo(user_id: number, access_token: string) {
id.value = user_id
setApiToken(access_token)
let user: UserDto
try {
user = await api
.get('/admin/me/')
.then((res) => res.data as UserDto)
} catch (err) {
console.error(`请求用户信息时失败:${err}`)
throw err
}
await loadWithUserInfo(user)
}
async function loadWithUserInfo(user: UserDto) {
name.value = user.name
display_name.value = user.display_name
email.value = user.email
phone.value = user.phone
avatar_url.value = user.avatar_url
background_url.value = user.background_url
description.value = user.description
is_admin.value = user.is_admin
isLogin.value = true
}
return {
isLogin,
id,
name,
display_name,
email,
phone,
avatar_url,
background_url,
description,
is_admin,
loadUserInfo,
loadWithUserInfo,
}
})
+3
View File
@@ -0,0 +1,3 @@
import markdownit from 'markdown-it'
export const md = markdownit({ html: true, breaks: true })
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios'
export const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
let authToken: string | null = null
export function setApiToken(token: string) {
authToken = token
}
api.interceptors.request.use((config) => {
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`
}
return config
})
+39
View File
@@ -0,0 +1,39 @@
/**
* /api/aii/model 端点返回的模型数据,包含冰冷的 provider id 以及查询得到的温暖的 provider name 和 base url。
*
* 创建新模型不使用此 interface。
*/
export interface AiiModelPublic {
id: number
model_name: string
max_context_length: number
provider_id: number
provider_name: string
base_url: string
}
export interface AiiProviderPublic {
id: number
name: string
base_url: string
api_key: string
}
export interface AiiProviderPublicWithoutKey {
id: number
name: string
base_url: string
}
export interface AiiTokenInfo {
type: 'usage'
completion_tokens: number
completion_tokens_details: object
prompt_tokens: number
prompt_tokens_details: object
total_tokens: number
// DeepSeek
prompt_cache_hit_tokens?: number
// DeepSeek
prompt_cache_miss_tokens?: number
}
+26
View File
@@ -0,0 +1,26 @@
export interface ChatroomPublic {
id: number
name: string
description: string
feature_image: string
script_template_id: number
script_template_version: string
}
export interface Chatroom extends ChatroomPublic {
script: string
content: string
}
export interface WordBook {
key_word: string
message: string
}
export interface ChatScript {
main_prompt: string
user_prefix: string
user_suffix: string
world_books: WordBook[]
}
+5
View File
@@ -0,0 +1,5 @@
export interface ReturnDto {
success: boolean
message?: string
result?: unknown
}
+1
View File
@@ -0,0 +1 @@
export const SEE_YOU_TOMORROW = '... . . -.-- --- ..- - --- -- --- .-. .-. --- .--'
+22
View File
@@ -0,0 +1,22 @@
export interface UserDto {
id: number
name: string
display_name: string
avatar_url: string
background_url: string
description: string
email: string
phone: string
is_admin: boolean
}
export interface UploadFileDto {
id: number
original_name: string
safe_name: string
download_url: string
uploader_id: number
}