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
+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>