merge v4.2.2

This commit is contained in:
RockYang
2025-12-02 11:18:30 +08:00
69 changed files with 19149 additions and 2793 deletions

View File

@@ -6,7 +6,7 @@ VUE_APP_ADMIN_USER=admin
VUE_APP_ADMIN_PASS=admin123
VUE_APP_KEY_PREFIX=GeekAI_DEV_
VUE_APP_TITLE="Geek-AI 创作系统"
VUE_APP_VERSION=v4.2.1
VUE_APP_VERSION=v4.2.2
VUE_APP_DOCS_URL=https://docs.geekai.me
VUE_APP_GITHUB_URL=https://github.com/yangjian102621/geekai
VUE_APP_GITEE_URL=https://gitee.com/blackfox/geekai

View File

@@ -1,7 +1,7 @@
VUE_APP_API_HOST=
VUE_APP_WS_HOST=
VUE_APP_KEY_PREFIX=GeekAI_
VUE_APP_VERSION=v4.2.1
VUE_APP_VERSION=v4.2.2
VUE_APP_TITLE="Geek-AI 创作系统"
VUE_APP_DOCS_URL=https://docs.geekai.me
VUE_APP_GITHUB_URL=https://github.com/yangjian102621/geekai

13730
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@openai/realtime-api-beta": "github:openai/openai-realtime-api-beta",
"animate.css": "^4.1.1",
"axios": "^0.27.2",
"clipboard": "^2.0.11",
@@ -33,12 +32,17 @@
"pinia": "^2.1.4",
"qrcode": "^1.5.3",
"qs": "^6.11.1",
"@better-scroll/core": "^2.5.1",
"@better-scroll/mouse-wheel": "^2.5.1",
"@better-scroll/observe-dom": "^2.5.1",
"@better-scroll/pull-up": "^2.5.1",
"@better-scroll/scroll-bar": "^2.5.1",
"sortablejs": "^1.15.0",
"three": "^0.128.0",
"v3-waterfall": "^1.3.3",
"vant": "^4.5.0",
"vue": "^3.2.13",
"vue-router": "^4.0.15"
"vue-router": "^4.0.15",
"vue-waterfall-plugin-next": "^2.6.5"
},
"devDependencies": {
"@babel/core": "7.18.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

BIN
web/public/images/voice.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -125,6 +125,7 @@
//.el-message-box
.el-message-box{
--el-messagebox-border-radius: 10px
--el-messagebox-padding-primary: 24px
}
.el-message-box__container{
//border-top: 1px solid #dbd3f4;

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -7,7 +7,7 @@
<div class="chat-item">
<div v-if="files.length > 0" class="file-list-box">
<div v-for="file in files">
<div v-for="file in files" :key="file.url">
<div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" />
</div>
@@ -17,7 +17,9 @@
</div>
<div class="body">
<div class="title">
<el-link :href="file.url" target="_blank" style="--el-font-weight-primary: bold">{{ file.name }} </el-link>
<el-link :href="file.url" target="_blank" style="--el-font-weight-primary: bold"
>{{ file.name }}
</el-link>
</div>
<div class="info">
<span>{{ GetFileType(file.ext) }}</span>
@@ -46,7 +48,7 @@
<div class="chat-item">
<div v-if="files.length > 0" class="file-list-box">
<div v-for="file in files">
<div v-for="file in files" :key="file.url">
<div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" />
</div>
@@ -56,7 +58,9 @@
</div>
<div class="body">
<div class="title">
<el-link :href="file.url" target="_blank" style="--el-font-weight-primary: bold">{{ file.name }} </el-link>
<el-link :href="file.url" target="_blank" style="--el-font-weight-primary: bold"
>{{ file.name }}
</el-link>
</div>
<div class="info">
<span>{{ GetFileType(file.ext) }}</span>
@@ -81,15 +85,15 @@
</template>
<script setup>
import { onMounted, ref } from "vue";
import { Clock } from "@element-plus/icons-vue";
import { httpPost } from "@/utils/http";
import hl from "highlight.js";
import { dateFormat, isImage, processPrompt } from "@/utils/libs";
import { FormatFileSize, GetFileIcon, GetFileType } from "@/store/system";
import emoji from "markdown-it-emoji";
import mathjaxPlugin from "markdown-it-mathjax3";
import MarkdownIt from "markdown-it";
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
import { httpPost } from '@/utils/http'
import { dateFormat, isImage, processPrompt } from '@/utils/libs'
import { Clock } from '@element-plus/icons-vue'
import hl from 'highlight.js'
import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { onMounted, ref } from 'vue'
const md = new MarkdownIt({
breaks: true,
@@ -97,91 +101,94 @@ const md = new MarkdownIt({
linkify: true,
typographer: true,
highlight: function (str, lang) {
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000);
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000)
// 显示复制代码按钮
const copyBtn = `<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
<textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
/<\/textarea>/g,
"&lt;/textarea>"
)}</textarea>`;
'&lt;/textarea>'
)}</textarea>`
if (lang && hl.getLanguage(lang)) {
const langHtml = `<span class="lang-name">${lang}</span>`;
const langHtml = `<span class="lang-name">${lang}</span>`
// 处理代码高亮
const preCode = hl.highlight(lang, str, true).value;
const preCode = hl.highlight(lang, str, true).value
// 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`;
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
}
// 处理代码高亮
const preCode = md.utils.escapeHtml(str);
const preCode = md.utils.escapeHtml(str)
// 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>`;
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>`
},
});
md.use(mathjaxPlugin);
md.use(emoji);
})
md.use(mathjaxPlugin)
md.use(emoji)
const props = defineProps({
data: {
type: Object,
default: {
content: "",
created_at: "",
content: '',
created_at: '',
tokens: 0,
model: "",
icon: "",
model: '',
icon: '',
},
},
listStyle: {
type: String,
default: "list",
default: 'list',
},
});
const finalTokens = ref(props.data.tokens);
const content = ref(processPrompt(props.data.content));
const files = ref([]);
})
const finalTokens = ref(props.data.tokens)
const content = ref(processPrompt(props.data.content))
const files = ref([])
onMounted(() => {
processFiles();
});
processFiles()
})
const processFiles = () => {
if (!props.data.content) {
return;
return
}
const linkRegex = /(https?:\/\/\S+)/g;
const links = props.data.content.match(linkRegex);
const urlPrefix = `${window.location.protocol}//${window.location.host}`;
// 提取图片|文件链接
const linkRegex = /(https?:\/\/\S+)/g
const links = props.data.content.match(linkRegex)
const urlPrefix = `${window.location.protocol}//${window.location.host}`
if (links) {
// 把本地链接转换为相对路径
const _links = links.map((link) => {
if (link.startsWith(urlPrefix)) {
return link.replace(urlPrefix, "");
return link.replace(urlPrefix, '')
}
return link;
});
return link
})
// 合并数组并去重
const urls = [...new Set([...links, ..._links])];
httpPost("/api/upload/list", { urls: urls })
const urls = [...new Set([...links, ..._links])]
httpPost('/api/upload/list', { urls: urls })
.then((res) => {
files.value = res.data.items;
files.value = res.data.items
for (let link of links) {
if (isExternalImg(link, files.value)) {
files.value.push({ url: link, ext: ".png" });
}
}
// for (let link of links) {
// if (isExternalImg(link, files.value)) {
// files.value.push({ url: link, ext: ".png" });
// }
// }
})
.catch(() => {});
.catch(() => {})
// 替换图片|文件链接
for (let link of links) {
content.value = content.value.replace(link, "");
content.value = content.value.replace(link, '')
}
}
content.value = md.render(content.value.trim());
};
content.value = md.render(content.value.trim())
}
const isExternalImg = (link, files) => {
return isImage(link) && !files.find((file) => file.url === link);
};
return isImage(link) && !files.find((file) => file.url === link)
}
</script>
<style lang="stylus">
@@ -195,7 +202,7 @@ const isExternalImg = (link, files) => {
width 100%
padding-bottom: 1.5rem;
padding-top: 1.5rem;
border-bottom: 0.5px solid var(--el-border-color);
// border-bottom: 0.5px solid var(--el-border-color);
.chat-line-inner {
display flex;
@@ -233,6 +240,8 @@ const isExternalImg = (link, files) => {
border 1px solid #e3e3e3
border-radius 10px
margin-bottom 10px
max-width 150px
max-height 150px
}
}
@@ -366,6 +375,8 @@ const isExternalImg = (link, files) => {
border 1px solid #e3e3e3
border-radius 10px
margin-bottom 10px
max-width 150px
max-height 150px
}
}

View File

@@ -1,111 +1,137 @@
<template>
<div class="chat-line chat-line-reply-list" v-if="listStyle === 'list'">
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" />
</div>
<div class="chat-reply">
<div class="chat-line chat-line-reply-list" v-if="listStyle === 'list'">
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" />
</div>
<div class="chat-item">
<div class="content-wrapper" v-html="md.render(processContent(data.content))"></div>
<div class="bar" v-if="data.created_at">
<span class="bar-item"
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
>
<span class="bar-item">tokens: {{ data.tokens }}</span>
<span class="bar-item">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
<el-icon class="copy-reply" :data-clipboard-text="data.content">
<DocumentCopy />
</el-icon>
</el-tooltip>
</span>
<span v-if="!readOnly">
<span class="bar-item" @click="reGenerate(data.prompt)">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-icon><Refresh /></el-icon>
<div class="chat-item">
<div class="content-wrapper" v-html="md.render(processContent(data.content))"></div>
<div class="bar flex text-gray-500" v-if="data.created_at">
<span class="bar-item text-sm">{{ dateFormat(data.created_at) }}</span>
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span> -->
<span class="bar-item">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
<el-icon class="copy-reply" :data-clipboard-text="data.content">
<DocumentCopy />
</el-icon>
</el-tooltip>
</span>
<span v-if="!readOnly" class="flex">
<span class="bar-item" @click="reGenerate(data.prompt)">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-icon><Refresh /></el-icon>
</el-tooltip>
</span>
<span class="bar-item" @click="synthesis(data.content)">
<el-tooltip class="box-item" effect="dark" content="生成语音朗读" placement="bottom">
<i class="iconfont icon-speaker"></i>
</el-tooltip>
<span class="bar-item">
<el-tooltip
class="box-item"
effect="dark"
content="生成语音朗读"
placement="bottom"
>
<i
class="iconfont icon-speaker"
v-if="!isPlaying"
@click="synthesis(data.content)"
></i>
<el-image class="voice-icon" :src="playIcon" v-else />
</el-tooltip>
</span>
</span>
</span>
<!-- <span class="bar-item">-->
<!-- <el-dropdown trigger="click">-->
<!-- <span class="el-dropdown-link">-->
<!-- <el-icon><More/></el-icon>-->
<!-- </span>-->
<!-- <template #dropdown>-->
<!-- <el-dropdown-menu>-->
<!-- <el-dropdown-item :icon="Headset" @click="synthesis(orgContent)">生成语音</el-dropdown-item>-->
<!-- </el-dropdown-menu>-->
<!-- </template>-->
<!-- </el-dropdown>-->
<!-- </span>-->
<!-- <span class="bar-item">-->
<!-- <el-dropdown trigger="click">-->
<!-- <span class="el-dropdown-link">-->
<!-- <el-icon><More/></el-icon>-->
<!-- </span>-->
<!-- <template #dropdown>-->
<!-- <el-dropdown-menu>-->
<!-- <el-dropdown-item :icon="Headset" @click="synthesis(orgContent)">生成语音</el-dropdown-item>-->
<!-- </el-dropdown-menu>-->
<!-- </template>-->
<!-- </el-dropdown>-->
<!-- </span>-->
</div>
</div>
</div>
</div>
</div>
<div class="chat-line chat-line-reply-chat" v-else>
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" />
</div>
<div class="chat-item">
<div class="content-wrapper">
<div class="content" v-html="md.render(processContent(data.content))"></div>
<div class="chat-line chat-line-reply-chat" v-else>
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" />
</div>
<div class="bar" v-if="data.created_at">
<span class="bar-item"
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
>
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span>-->
<span class="bar-item bg">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
<el-icon class="copy-reply" :data-clipboard-text="data.content">
<DocumentCopy />
</el-icon>
</el-tooltip>
</span>
<span v-if="!readOnly">
<span class="bar-item bg" @click="reGenerate(data.prompt)">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-icon><Refresh /></el-icon>
<div class="chat-item">
<div class="content-wrapper">
<div class="content" v-html="md.render(processContent(data.content))"></div>
</div>
<div class="bar text-gray-500" v-if="data.created_at">
<span class="bar-item text-sm"> {{ dateFormat(data.created_at) }}</span>
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span>-->
<span class="bar-item bg">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
<el-icon class="copy-reply" :data-clipboard-text="data.content">
<DocumentCopy />
</el-icon>
</el-tooltip>
</span>
<span v-if="!readOnly" class="flex">
<span class="bar-item bg" @click="reGenerate(data.prompt)">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-icon><Refresh /></el-icon>
</el-tooltip>
</span>
<span class="bar-item bg" @click="synthesis(data.content)">
<el-tooltip class="box-item" effect="dark" content="生成语音朗读" placement="bottom">
<i class="iconfont icon-speaker"></i>
</el-tooltip>
<span class="bar-item bg">
<el-tooltip
class="box-item"
effect="dark"
content="生成语音朗读"
placement="bottom"
v-if="!isPlaying"
>
<i class="iconfont icon-speaker" @click="synthesis(data.content)"></i>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="暂停播放"
placement="bottom"
v-else
>
<el-image class="voice-icon" :src="playIcon" @click="stopSynthesis()" />
</el-tooltip>
</span>
</span>
</span>
</div>
</div>
</div>
</div>
<audio ref="audio" @ended="isPlaying = false" />
</div>
</template>
<script setup>
import {Clock, DocumentCopy, Refresh} from "@element-plus/icons-vue";
import {ElMessage} from "element-plus";
import {dateFormat, processContent} from "@/utils/libs";
import hl from "highlight.js";
import emoji from "markdown-it-emoji";
import mathjaxPlugin from "markdown-it-mathjax3";
import MarkdownIt from "markdown-it";
import { useSharedStore } from '@/store/sharedata'
import { httpPost } from '@/utils/http'
import { dateFormat, processContent } from '@/utils/libs'
import { DocumentCopy, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import hl from 'highlight.js'
import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { ref } from 'vue'
// eslint-disable-next-line no-undef,no-unused-vars
const props = defineProps({
data: {
type: Object,
default: {
icon: "",
content: "",
created_at: "",
icon: '',
content: '',
created_at: '',
tokens: 0,
},
},
@@ -115,9 +141,14 @@ const props = defineProps({
},
listStyle: {
type: String,
default: "list",
default: 'list',
},
});
})
const audio = ref(null)
const isPlaying = ref(false)
const playIcon = ref('/images/voice.gif')
const store = useSharedStore()
const md = new MarkdownIt({
breaks: true,
@@ -125,49 +156,77 @@ const md = new MarkdownIt({
linkify: true,
typographer: true,
highlight: function (str, lang) {
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000);
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000)
// 显示复制代码按钮
const copyBtn = `<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
<textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
/<\/textarea>/g,
"&lt;/textarea>"
)}</textarea>`;
'&lt;/textarea>'
)}</textarea>`
if (lang && hl.getLanguage(lang)) {
const langHtml = `<span class="lang-name">${lang}</span>`;
const langHtml = `<span class="lang-name">${lang}</span>`
// 处理代码高亮
const preCode = hl.highlight(str, { language: lang }).value;
const preCode = hl.highlight(str, { language: lang }).value
// 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`;
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
}
// 处理代码高亮
const preCode = md.utils.escapeHtml(str);
const preCode = md.utils.escapeHtml(str)
// 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>`;
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>`
},
});
md.use(mathjaxPlugin);
md.use(emoji);
const emits = defineEmits(["regen"]);
})
md.use(mathjaxPlugin)
md.use(emoji)
const emits = defineEmits(['regen'])
if (!props.data.icon) {
props.data.icon = "images/gpt-icon.png";
props.data.icon = 'images/gpt-icon.png'
}
const synthesis = (text) => {
console.log(text);
ElMessage.info("语音合成功能暂不可用");
};
isPlaying.value = true
httpPost('/api/chat/tts', { text: text, model_id: store.ttsModel }, { responseType: 'blob' })
.then((response) => {
// 创建 Blob 对象,明确指定 MIME 类型
const blob = new Blob([response], { type: 'audio/mpeg' }) // 假设音频格式为 MP3
const audioUrl = URL.createObjectURL(blob)
// 播放音频
audio.value.src = audioUrl
audio.value
.play()
.then(() => {
// 播放完成后释放 URL
URL.revokeObjectURL(audioUrl)
})
.catch(() => {
ElMessage.error('音频播放失败,请检查浏览器是否支持该音频格式')
isPlaying.value = false
})
})
.catch((e) => {
ElMessage.error('语音合成失败:' + e.message)
isPlaying.value = false
})
}
const stopSynthesis = () => {
isPlaying.value = false
audio.value.pause()
audio.value.currentTime = 0
}
// 重新生成
const reGenerate = (prompt) => {
console.log(prompt);
emits("regen", prompt);
};
console.log(prompt)
emits('regen', prompt)
}
</script>
<style lang="stylus">
@import '@/assets/css/markdown/vue.css';
.chat-page,.chat-export {
--font-family: Menlo,"微软雅黑","Roboto Mono","Courier New",Courier,monospace,"Inter",sans-serif;
font-family: var(--font-family);
@@ -265,7 +324,7 @@ const reGenerate = (prompt) => {
// 代码快
blockquote {
margin 0
margin 0 0 0.8rem 0
background-color: var(--quote-bg-color);
padding: 0.8rem 1.5rem;
color: var(--quote-text-color);
@@ -284,7 +343,8 @@ const reGenerate = (prompt) => {
width 100%
padding-bottom: 1.5rem;
padding-top: 1.5rem;
border-bottom: 0.5px solid var(--el-border-color);
border: 1px solid var(--el-border-color);
border-radius: 10px;
.chat-line-inner {
display flex;
@@ -324,10 +384,18 @@ const reGenerate = (prompt) => {
padding 10px 10px 10px 0;
.bar-item {
padding 3px 5px;
margin-right 10px;
border-radius 5px;
cursor pointer
display flex
align-items center
justify-content center
height 26px
.voice-icon {
width 20px
height 20px
}
.el-icon {
position relative
@@ -403,11 +471,21 @@ const reGenerate = (prompt) => {
.bar {
padding 10px 10px 10px 0;
display flex
.bar-item {
padding 3px 5px;
margin-right 10px;
border-radius 5px;
display flex
align-items center
justify-content center
height 26px
.voice-icon {
width 20px
height 20px
}
.el-icon {
position relative

View File

@@ -18,19 +18,28 @@
<el-form-item label="流式输出:">
<el-switch v-model="data.stream" @change="(val) => {store.setChatStream(val)}" />
</el-form-item>
<el-form-item label="语音音色:">
<el-select v-model="data.ttsModel" placeholder="请选择语音音色" @change="changeTTSModel">
<el-option v-for="v in models" :value="v.id" :label="v.name" :key="v.id">
{{ v.name }}
</el-option>
</el-select>
</el-form-item>
</el-form>
</div>
</el-dialog>
</template>
<script setup>
import {computed, ref} from "vue"
import {computed, ref, onMounted} from "vue"
import {useSharedStore} from "@/store/sharedata";
import {httpGet} from "@/utils/http";
const store = useSharedStore();
const data = ref({
style: store.chatListStyle,
stream: store.chatStream,
ttsModel: store.ttsModel,
})
// eslint-disable-next-line no-undef
const props = defineProps({
@@ -44,6 +53,20 @@ const emits = defineEmits(['hide']);
const close = function () {
emits('hide', false);
}
const models = ref([]);
onMounted(() => {
// 获取模型列表
httpGet("/api/model/list?type=tts").then((res) => {
models.value = res.data;
if (!data.ttsModel) {
store.setTtsModel(models.value[0].id);
}
})
})
const changeTTSModel = (item) => {
store.setTtsModel(item);
}
</script>
<style lang="stylus" scoped>

View File

@@ -25,7 +25,11 @@
<template v-for="subItem in item.subs">
<el-sub-menu v-if="subItem.subs" :index="subItem.index" :key="subItem.index">
<template #title>{{ subItem.title }}</template>
<el-menu-item v-for="(threeItem, i) in subItem.subs" :key="i" :index="threeItem.index">
<el-menu-item
v-for="(threeItem, i) in subItem.subs"
:key="i"
:index="threeItem.index"
>
{{ threeItem.title }}
</el-menu-item>
</el-sub-menu>
@@ -48,125 +52,135 @@
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { setMenuItems, useSidebarStore } from "@/store/sidebar";
import { httpGet } from "@/utils/http";
import { ElMessage } from "element-plus";
import { useRoute } from "vue-router";
import { useSharedStore } from "@/store/sharedata";
import { useSharedStore } from '@/store/sharedata'
import { setMenuItems, useSidebarStore } from '@/store/sidebar'
import { httpGet } from '@/utils/http'
import { ElMessage } from 'element-plus'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const title = ref("");
const logo = ref("");
const title = ref('')
const logo = ref('')
// 加载系统配置
httpGet("/api/admin/config/get?key=system")
httpGet('/api/admin/config/get?key=system')
.then((res) => {
title.value = res.data.admin_title;
logo.value = res.data.logo;
title.value = res.data.admin_title
logo.value = res.data.logo
})
.catch((e) => {
ElMessage.error("加载系统配置失败: " + e.message);
});
const store = useSharedStore();
const theme = ref(store.theme);
ElMessage.error('加载系统配置失败: ' + e.message)
})
const store = useSharedStore()
const theme = ref(store.theme)
watch(
() => store.theme,
(val) => {
theme.value = val;
theme.value = val
}
);
)
const items = [
{
icon: "home",
index: "/admin/dashboard",
title: "仪表盘",
icon: 'home',
index: '/admin/dashboard',
title: '仪表盘',
},
{
icon: "user-fill",
index: "/admin/user",
title: "用户管理",
icon: 'user-fill',
index: '/admin/user',
title: '用户管理',
},
{
icon: "menu",
index: "1",
title: "应用管理",
icon: 'menu',
index: '1',
title: '应用管理',
subs: [
{
index: "/admin/app",
title: "应用列表",
index: '/admin/app',
title: '应用列表',
icon: 'sub-menu',
},
{
index: "/admin/app/type",
title: "应用分类",
index: '/admin/app/type',
title: '应用分类',
icon: 'chuangzuo',
},
],
},
{
icon: "api-key",
index: "/admin/apikey",
title: "API-KEY",
icon: 'api-key',
index: '/admin/apikey',
title: 'API-KEY',
},
{
icon: "model",
index: "/admin/chat/model",
title: "模型管理",
icon: 'model',
index: '/admin/chat/model',
title: '模型管理',
},
{
icon: "recharge",
index: "/admin/product",
title: "充值产品",
icon: 'recharge',
index: '/admin/product',
title: '充值产品',
},
{
icon: "order",
index: "/admin/order",
title: "充值订单",
icon: 'order',
index: '/admin/order',
title: '充值订单',
},
{
icon: "reward",
index: "/admin/redeem",
title: "兑换码",
icon: 'reward',
index: '/admin/redeem',
title: '兑换码',
},
{
icon: "control",
index: "/admin/functions",
title: "函数管理",
icon: 'control',
index: '/admin/functions',
title: '函数管理',
},
{
icon: "prompt",
index: "/admin/chats",
title: "对话管理",
icon: 'menu',
index: '2',
title: '创作记录',
subs: [
{
icon: 'prompt',
index: '/admin/chats',
title: '对话记录',
},
{
icon: 'image',
index: '/admin/images',
title: '绘图记录',
},
{
icon: 'mp3',
index: '/admin/medias',
title: '音视频记录',
},
],
},
{
icon: 'role',
index: '/admin/manger',
title: '管理员',
},
{
icon: "image",
index: "/admin/images",
title: "绘图管理",
icon: 'config',
index: '/admin/system',
title: '系统设置',
},
{
icon: "mp3",
index: "/admin/medias",
title: "音视频管理",
icon: 'log',
index: '/admin/powerLog',
title: '用户算力日志',
},
{
icon: "role",
index: "/admin/manger",
title: "管理员",
},
{
icon: "config",
index: "/admin/system",
title: "系统设置",
},
{
icon: "log",
index: "/admin/powerLog",
title: "用户算力日志",
},
{
icon: "log",
index: "/admin/loginLog",
title: "用户登录日志",
icon: 'log',
index: '/admin/loginLog',
title: '用户登录日志',
},
// {
// icon: 'menu',
@@ -191,15 +205,15 @@ const items = [
// },
// ],
// },
];
]
const route = useRoute();
const route = useRoute()
const onRoutes = computed(() => {
return route.path;
});
return route.path
})
const sidebar = useSidebarStore();
setMenuItems(items);
const sidebar = useSidebarStore()
setMenuItems(items)
</script>
<style scoped lang="stylus">

View File

@@ -64,8 +64,6 @@ import {
Uploader,
} from "vant";
import { router } from "@/router";
import "v3-waterfall/dist/style.css";
import V3waterfall from "v3-waterfall";
import "@/assets/css/theme-dark.styl";
import "@/assets/css/theme-light.styl";
import "@/assets/css/common.styl";
@@ -104,7 +102,6 @@ app.use(ShareSheet);
app.use(Switch);
app.use(Uploader);
app.use(Tag);
app.use(V3waterfall);
app.use(Overlay);
app.use(Col);
app.use(Row);

View File

@@ -5,357 +5,357 @@
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import {createRouter, createWebHistory} from "vue-router";
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
name: "Index",
path: "/",
meta: { title: "首页" },
component: () => import("@/views/Index.vue"),
name: 'Index',
path: '/',
meta: { title: '首页' },
component: () => import('@/views/Index.vue'),
},
{
name: "home",
path: "/home",
redirect: "/chat",
component: () => import("@/views/Home.vue"),
name: 'home',
path: '/home',
redirect: '/chat',
component: () => import('@/views/Home.vue'),
children: [
{
name: "chat",
path: "/chat",
meta: { title: "创作中心" },
component: () => import("@/views/ChatPlus.vue"),
name: 'chat',
path: '/chat',
meta: { title: '创作中心' },
component: () => import('@/views/ChatPlus.vue'),
},
{
name: "chat-id",
path: "/chat/:id",
meta: { title: "创作中心" },
component: () => import("@/views/ChatPlus.vue"),
name: 'chat-id',
path: '/chat/:id',
meta: { title: '创作中心' },
component: () => import('@/views/ChatPlus.vue'),
},
{
name: "image-mj",
path: "/mj",
meta: { title: "MidJourney 绘画中心" },
component: () => import("@/views/ImageMj.vue"),
name: 'image-mj',
path: '/mj',
meta: { title: 'MidJourney 绘画中心' },
component: () => import('@/views/ImageMj.vue'),
},
{
name: "image-sd",
path: "/sd",
meta: { title: "stable diffusion 绘画中心" },
component: () => import("@/views/ImageSd.vue"),
name: 'image-sd',
path: '/sd',
meta: { title: 'stable diffusion 绘画中心' },
component: () => import('@/views/ImageSd.vue'),
},
{
name: "member",
path: "/member",
meta: { title: "会员充值中心" },
component: () => import("@/views/Member.vue"),
name: 'member',
path: '/member',
meta: { title: '会员充值中心' },
component: () => import('@/views/Member.vue'),
},
{
name: "chat-app",
path: "/apps",
meta: { title: "应用中心" },
component: () => import("@/views/ChatApps.vue"),
name: 'chat-app',
path: '/apps',
meta: { title: '应用中心' },
component: () => import('@/views/ChatApps.vue'),
},
{
name: "images",
path: "/images-wall",
meta: { title: "作品展示" },
component: () => import("@/views/ImagesWall.vue"),
name: 'images',
path: '/images-wall',
meta: { title: '作品展示' },
component: () => import('@/views/ImagesWall.vue'),
},
{
name: "user-invitation",
path: "/invite",
meta: { title: "推广计划" },
component: () => import("@/views/Invitation.vue"),
name: 'user-invitation',
path: '/invite',
meta: { title: '推广计划' },
component: () => import('@/views/Invitation.vue'),
},
{
name: "powerLog",
path: "/powerLog",
meta: { title: "消费日志" },
component: () => import("@/views/PowerLog.vue"),
name: 'powerLog',
path: '/powerLog',
meta: { title: '消费日志' },
component: () => import('@/views/PowerLog.vue'),
},
{
name: "xmind",
path: "/xmind",
meta: { title: "思维导图" },
component: () => import("@/views/MarkMap.vue"),
name: 'xmind',
path: '/xmind',
meta: { title: '思维导图' },
component: () => import('@/views/MarkMap.vue'),
},
{
name: "dalle",
path: "/dalle",
meta: { title: "DALLE-3" },
component: () => import("@/views/Dalle.vue"),
name: 'dalle',
path: '/dalle',
meta: { title: 'DALLE-3' },
component: () => import('@/views/Dalle.vue'),
},
{
name: "suno",
path: "/suno",
meta: { title: "Suno音乐创作" },
component: () => import("@/views/Suno.vue"),
name: 'suno',
path: '/suno',
meta: { title: 'Suno音乐创作' },
component: () => import('@/views/Suno.vue'),
},
{
name: "ExternalLink",
path: "/external",
component: () => import("@/views/ExternalPage.vue"),
name: 'ExternalLink',
path: '/external',
component: () => import('@/views/ExternalPage.vue'),
},
{
name: "song",
path: "/song/:id",
meta: { title: "Suno音乐播放" },
component: () => import("@/views/Song.vue"),
name: 'song',
path: '/song/:id',
meta: { title: 'Suno音乐播放' },
component: () => import('@/views/Song.vue'),
},
{
name: "luma",
path: "/luma",
meta: { title: "Luma视频创作" },
component: () => import("@/views/Luma.vue"),
name: 'luma',
path: '/luma',
meta: { title: 'Luma视频创作' },
component: () => import('@/views/Luma.vue'),
},
{
name: "keling",
path: "/keling",
meta: { title: "KeLing视频创作" },
component: () => import("@/views/KeLing.vue"),
name: 'keling',
path: '/keling',
meta: { title: 'KeLing视频创作' },
component: () => import('@/views/KeLing.vue'),
},
],
},
{
name: "chat-export",
path: "/chat/export",
meta: { title: "导出会话记录" },
component: () => import("@/views/ChatExport.vue"),
name: 'chat-export',
path: '/chat/export',
meta: { title: '导出会话记录' },
component: () => import('@/views/ChatExport.vue'),
},
{
name: "login",
path: "/login",
meta: { title: "用户登录" },
component: () => import("@/views/Login.vue"),
name: 'login',
path: '/login',
meta: { title: '用户登录' },
component: () => import('@/views/Login.vue'),
},
{
name: "login-callback",
path: "/login/callback",
meta: { title: "用户登录" },
component: () => import("@/views/LoginCallback.vue"),
name: 'login-callback',
path: '/login/callback',
meta: { title: '用户登录' },
component: () => import('@/views/LoginCallback.vue'),
},
{
name: "register",
path: "/register",
name: 'register',
path: '/register',
meta: { title: "用户注册" },
component: () => import("@/views/Register.vue"),
meta: { title: '用户注册' },
component: () => import('@/views/Register.vue'),
},
{
name: "resetpassword",
path: "/resetpassword",
meta: { title: "重置密码" },
component: () => import("@/views/Resetpassword.vue"),
name: 'resetpassword',
path: '/resetpassword',
meta: { title: '重置密码' },
component: () => import('@/views/Resetpassword.vue'),
},
{
path: "/admin/login",
name: "admin-login",
meta: { title: "控制台登录" },
component: () => import("@/views/admin/Login.vue"),
path: '/admin/login',
name: 'admin-login',
meta: { title: '控制台登录' },
component: () => import('@/views/admin/Login.vue'),
},
{
path: "/payReturn",
name: "pay-return",
meta: { title: "支付回调" },
component: () => import("@/views/PayReturn.vue"),
path: '/payReturn',
name: 'pay-return',
meta: { title: '支付回调' },
component: () => import('@/views/PayReturn.vue'),
},
{
name: "admin",
path: "/admin",
redirect: "/admin/dashboard",
component: () => import("@/views/admin/Home.vue"),
meta: { title: "Geek-AI 控制台" },
name: 'admin',
path: '/admin',
redirect: '/admin/dashboard',
component: () => import('@/views/admin/Home.vue'),
meta: { title: 'Geek-AI 控制台' },
children: [
{
path: "/admin/dashboard",
name: "admin-dashboard",
meta: { title: "仪表盘" },
component: () => import("@/views/admin/Dashboard.vue"),
path: '/admin/dashboard',
name: 'admin-dashboard',
meta: { title: '仪表盘' },
component: () => import('@/views/admin/Dashboard.vue'),
},
{
path: "/admin/system",
name: "admin-system",
meta: { title: "系统设置" },
component: () => import("@/views/admin/SysConfig.vue"),
path: '/admin/system',
name: 'admin-system',
meta: { title: '系统设置' },
component: () => import('@/views/admin/SysConfig.vue'),
},
{
path: "/admin/user",
name: "admin-user",
meta: { title: "用户管理" },
component: () => import("@/views/admin/Users.vue"),
path: '/admin/user',
name: 'admin-user',
meta: { title: '用户管理' },
component: () => import('@/views/admin/Users.vue'),
},
{
path: "/admin/app",
name: "admin-app",
meta: { title: "应用列表" },
component: () => import("@/views/admin/Apps.vue"),
path: '/admin/app',
name: 'admin-app',
meta: { title: '应用列表' },
component: () => import('@/views/admin/Apps.vue'),
},
{
path: "/admin/app/type",
name: "admin-app-type",
meta: { title: "应用分类" },
component: () => import("@/views/admin/AppType.vue"),
path: '/admin/app/type',
name: 'admin-app-type',
meta: { title: '应用分类' },
component: () => import('@/views/admin/AppType.vue'),
},
{
path: "/admin/apikey",
name: "admin-apikey",
meta: { title: "API-KEY 管理" },
component: () => import("@/views/admin/ApiKey.vue"),
path: '/admin/apikey',
name: 'admin-apikey',
meta: { title: 'API-KEY 管理' },
component: () => import('@/views/admin/ApiKey.vue'),
},
{
path: "/admin/chat/model",
name: "admin-chat-model",
meta: { title: "语言模型" },
component: () => import("@/views/admin/ChatModel.vue"),
path: '/admin/chat/model',
name: 'admin-chat-model',
meta: { title: '语言模型' },
component: () => import('@/views/admin/ChatModel.vue'),
},
{
path: "/admin/product",
name: "admin-product",
meta: { title: "充值产品" },
component: () => import("@/views/admin/Product.vue"),
path: '/admin/product',
name: 'admin-product',
meta: { title: '充值产品' },
component: () => import('@/views/admin/Product.vue'),
},
{
path: "/admin/order",
name: "admin-order",
meta: { title: "充值订单" },
component: () => import("@/views/admin/Order.vue"),
path: '/admin/order',
name: 'admin-order',
meta: { title: '充值订单' },
component: () => import('@/views/admin/Order.vue'),
},
{
path: "/admin/redeem",
name: "admin-redeem",
meta: { title: "兑换码管理" },
component: () => import("@/views/admin/Redeem.vue"),
path: '/admin/redeem',
name: 'admin-redeem',
meta: { title: '兑换码管理' },
component: () => import('@/views/admin/Redeem.vue'),
},
{
path: "/admin/loginLog",
name: "admin-loginLog",
meta: { title: "登录日志" },
component: () => import("@/views/admin/LoginLog.vue"),
path: '/admin/loginLog',
name: 'admin-loginLog',
meta: { title: '登录日志' },
component: () => import('@/views/admin/LoginLog.vue'),
},
{
path: "/admin/functions",
name: "admin-functions",
meta: { title: "函数管理" },
component: () => import("@/views/admin/Functions.vue"),
path: '/admin/functions',
name: 'admin-functions',
meta: { title: '函数管理' },
component: () => import('@/views/admin/Functions.vue'),
},
{
path: "/admin/chats",
name: "admin-chats",
meta: { title: "对话管理" },
component: () => import("@/views/admin/ChatList.vue"),
path: '/admin/chats',
name: 'admin-chats',
meta: { title: '对话管理' },
component: () => import('@/views/admin/records/ChatList.vue'),
},
{
path: "/admin/images",
name: "admin-images",
meta: { title: "绘图管理" },
component: () => import("@/views/admin/ImageList.vue"),
path: '/admin/images',
name: 'admin-images',
meta: { title: '绘图管理' },
component: () => import('@/views/admin/records/ImageList.vue'),
},
{
path: "/admin/medias",
name: "admin-medias",
meta: { title: "音视频管理" },
component: () => import("@/views/admin/Medias.vue"),
path: '/admin/medias',
name: 'admin-medias',
meta: { title: '音视频管理' },
component: () => import('@/views/admin/records/Medias.vue'),
},
{
path: "/admin/powerLog",
name: "admin-power-log",
meta: { title: "算力日志" },
component: () => import("@/views/admin/PowerLog.vue"),
path: '/admin/powerLog',
name: 'admin-power-log',
meta: { title: '算力日志' },
component: () => import('@/views/admin/PowerLog.vue'),
},
{
path: "/admin/manger",
name: "admin-manger",
meta: { title: "管理员" },
component: () => import("@/views/admin/Manager.vue"),
path: '/admin/manger',
name: 'admin-manger',
meta: { title: '管理员' },
component: () => import('@/views/admin/Manager.vue'),
},
],
},
{
name: "mobile-login",
path: "/mobile/login",
meta: { title: "用户登录" },
component: () => import("@/views/mobile/Login.vue"),
name: 'mobile-login',
path: '/mobile/login',
meta: { title: '用户登录' },
component: () => import('@/views/mobile/Login.vue'),
},
{
name: "mobile",
path: "/mobile",
meta: { title: "首页" },
component: () => import("@/views/mobile/Home.vue"),
redirect: "/mobile/index",
name: 'mobile',
path: '/mobile',
meta: { title: '首页' },
component: () => import('@/views/mobile/Home.vue'),
redirect: '/mobile/index',
children: [
{
path: "/mobile/index",
name: "mobile-index",
component: () => import("@/views/mobile/Index.vue"),
path: '/mobile/index',
name: 'mobile-index',
component: () => import('@/views/mobile/Index.vue'),
},
{
path: "/mobile/chat",
name: "mobile-chat",
component: () => import("@/views/mobile/ChatList.vue"),
path: '/mobile/chat',
name: 'mobile-chat',
component: () => import('@/views/mobile/ChatList.vue'),
},
{
path: "/mobile/image",
name: "mobile-image",
component: () => import("@/views/mobile/Image.vue"),
path: '/mobile/image',
name: 'mobile-image',
component: () => import('@/views/mobile/Image.vue'),
},
{
path: "/mobile/profile",
name: "mobile-profile",
component: () => import("@/views/mobile/Profile.vue"),
path: '/mobile/profile',
name: 'mobile-profile',
component: () => import('@/views/mobile/Profile.vue'),
},
{
path: "/mobile/imgWall",
name: "mobile-img-wall",
component: () => import("@/views/mobile/pages/ImgWall.vue"),
path: '/mobile/imgWall',
name: 'mobile-img-wall',
component: () => import('@/views/mobile/pages/ImgWall.vue'),
},
{
path: "/mobile/chat/session",
name: "mobile-chat-session",
component: () => import("@/views/mobile/ChatSession.vue"),
path: '/mobile/chat/session',
name: 'mobile-chat-session',
component: () => import('@/views/mobile/ChatSession.vue'),
},
{
path: "/mobile/chat/export",
name: "mobile-chat-export",
component: () => import("@/views/mobile/ChatExport.vue"),
path: '/mobile/chat/export',
name: 'mobile-chat-export',
component: () => import('@/views/mobile/ChatExport.vue'),
},
{
path: '/mobile/apps',
name: 'mobile-apps',
component: () => import('@/views/mobile/Apps.vue'),
},
],
},
{
name: "test",
path: "/test",
meta: { title: "测试页面" },
component: () => import("@/views/Test.vue"),
name: 'test',
path: '/test',
meta: { title: '测试页面' },
component: () => import('@/views/Test.vue'),
},
{
name: "test2",
path: "/test2",
meta: { title: "测试页面" },
component: () => import("@/views/RealtimeTest.vue"),
name: 'NotFound',
path: '/:all(.*)',
meta: { title: '页面没有找到' },
component: () => import('@/views/404.vue'),
},
{
name: "NotFound",
path: "/:all(.*)",
meta: { title: "页面没有找到" },
component: () => import("@/views/404.vue"),
},
];
]
// console.log(MY_VARIABLE)
const router = createRouter({
history: createWebHistory(),
routes: routes,
});
})
let prevRoute = null;
let prevRoute = null
// dynamic change the title when router change
router.beforeEach((to, from, next) => {
document.title = to.meta.title;
prevRoute = from;
next();
});
document.title = to.meta.title
prevRoute = from
next()
})
export { router, prevRoute };
export { router, prevRoute }

View File

@@ -1,5 +1,82 @@
import {defineStore} from "pinia";
import Storage from "good-storage";
import errorIcon from "@/assets/img/failed.png";
import loadingIcon from "@/assets/img/loading.gif";
let waterfallOptions = {
// 唯一key值
rowKey: "id",
// 卡片之间的间隙
gutter: 10,
// 是否有周围的gutter
hasAroundGutter: true,
// 卡片在PC上的宽度
width: 200,
// 自定义行显示个数,主要用于对移动端的适配
breakpoints: {
3840: {
// 4K下
rowPerView: 8,
},
2560: {
// 2K下
rowPerView: 7,
},
1920: {
// 2K下
rowPerView: 6,
},
1600: {
// 2K下
rowPerView: 5,
},
1366: {
// 2K下
rowPerView: 4,
},
800: {
// 当屏幕宽度小于等于800
rowPerView: 3,
},
500: {
// 当屏幕宽度小于等于500
rowPerView: 2,
},
},
// 动画效果
animationEffect: "animate__fadeInUp",
// 动画时间
animationDuration: 1000,
// 动画延迟
animationDelay: 300,
animationCancel: false,
// 背景色
backgroundColor: "",
// imgSelector
imgSelector: "img_thumb",
// 是否跨域
crossOrigin: true,
// 加载配置
loadProps: {
loading: loadingIcon,
error: errorIcon,
ratioCalculator: (width, height) => {
const minRatio = 3 / 4;
const maxRatio = 4 / 3;
const curRatio = height / width;
if (curRatio < minRatio) {
return minRatio;
} else if (curRatio > maxRatio) {
return maxRatio;
} else {
return curRatio;
}
},
},
// 是否懒加载
lazyload: true,
align: "center",
}
export const useSharedStore = defineStore("shared", {
state: () => ({
@@ -10,6 +87,8 @@ export const useSharedStore = defineStore("shared", {
theme: Storage.get("theme", "light"),
isLogin: false,
chatListExtend: Storage.get("chat_list_extend", true),
ttsModel: Storage.get("tts_model", ""),
waterfallOptions,
}),
getters: {},
actions: {
@@ -74,5 +153,10 @@ export const useSharedStore = defineStore("shared", {
setIsLogin(value) {
this.isLogin = value;
},
setTtsModel(value) {
this.ttsModel = value;
Storage.set("tts_model", value);
},
},
});

View File

@@ -464,7 +464,7 @@ onUnmounted(() => {
// 初始化数据
const initData = () => {
// 加载模型
httpGet("/api/model/list")
httpGet("/api/model/list?type=chat")
.then((res) => {
models.value = res.data;
if (!modelID.value) {
@@ -751,12 +751,13 @@ const sendMessage = function () {
}
// 如果携带了文件,则串上文件地址
let content = prompt.value;
if (files.value.length === 1) {
if (files.value.length > 0) {
content += files.value.map((file) => file.url).join(" ");
} else if (files.value.length > 1) {
showMessageError("当前只支持上传一个文件!");
return false;
}
// else if (files.value.length > 1) {
// showMessageError("当前只支持上传一个文件!");
// return false;
// }
// 追加消息
chatData.value.push({
type: "prompt",

View File

@@ -11,7 +11,12 @@
<el-form-item label="生图模型">
<template #default>
<div class="form-item-inner">
<el-select v-model="selectedModel" style="width: 150px" placeholder="请选择模型" @change="changeModel">
<el-select
v-model="selectedModel"
style="width: 150px"
placeholder="请选择模型"
@change="changeModel"
>
<el-option v-for="v in models" :label="v.name" :value="v" :key="v.value" />
</el-select>
</div>
@@ -24,7 +29,12 @@
<template #default>
<div class="form-item-inner">
<el-select v-model="params.quality" style="width: 150px">
<el-option v-for="v in qualities" :label="v.name" :value="v.value" :key="v.value" />
<el-option
v-for="v in qualities"
:label="v.name"
:value="v.value"
:key="v.value"
/>
</el-select>
</div>
</template>
@@ -48,9 +58,18 @@
<template #default>
<div class="form-item-inner">
<el-select v-model="params.style" style="width: 150px">
<el-option v-for="v in styles" :label="v.name" :value="v.value" :key="v.value" />
<el-option
v-for="v in styles"
:label="v.name"
:value="v.value"
:key="v.value"
/>
</el-select>
<el-tooltip content="生动使模型倾向于生成超真实和戏剧性的图像" raw-content placement="right">
<el-tooltip
content="生动使模型倾向于生成超真实和戏剧性的图像"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -73,7 +92,13 @@
</div>
<el-row class="text-info">
<el-button class="generate-btn" size="small" @click="generatePrompt" color="#5865f2" :disabled="isGenerating">
<el-button
class="generate-btn"
size="small"
@click="generatePrompt"
color="#5865f2"
:disabled="isGenerating"
>
<i class="iconfont icon-chuangzuo" style="margin-right: 5px"></i>
<span>生成专业绘画指令</span>
</el-button>
@@ -97,111 +122,148 @@
</div>
</div>
<div class="task-list-box pl-6 pr-6 pb-4 pt-4 h-dvh">
<div class="task-list-inner" :style="{ height: listBoxHeight + 'px' }">
<div class="task-list-inner">
<div class="job-list-box">
<h2 class="text-xl">任务列表</h2>
<task-list :list="runningJobs" />
<template v-if="finishedJobs.length > 0">
<h2 class="text-xl">创作记录</h2>
<div class="finish-job-list">
<div class="finish-job-list mt-3">
<div v-if="finishedJobs.length > 0">
<v3-waterfall
id="waterfall"
<Waterfall
:list="finishedJobs"
srcKey="img_thumb"
:gap="20"
:bottomGap="-10"
:colWidth="colWidth"
:distanceToScroll="100"
:isLoading="loading"
:isOver="isOver"
@scrollReachBottom="fetchFinishJobs()"
:row-key="waterfallOptions.rowKey"
:gutter="waterfallOptions.gutter"
:has-around-gutter="waterfallOptions.hasAroundGutter"
:width="waterfallOptions.width"
:breakpoints="waterfallOptions.breakpoints"
:img-selector="waterfallOptions.imgSelector"
:background-color="waterfallOptions.backgroundColor"
:animation-effect="waterfallOptions.animationEffect"
:animation-duration="waterfallOptions.animationDuration"
:animation-delay="waterfallOptions.animationDelay"
:animation-cancel="waterfallOptions.animationCancel"
:lazyload="waterfallOptions.lazyload"
:load-props="waterfallOptions.loadProps"
:cross-origin="waterfallOptions.crossOrigin"
:align="waterfallOptions.align"
:is-loading="loading"
:is-over="isOver"
@afterRender="loading = false"
>
<template #default="slotProp">
<div class="job-item">
<el-image
v-if="slotProp.item.img_url !== ''"
@click="previewImg(slotProp.item)"
:src="slotProp.item['img_thumb']"
fit="cover"
loading="lazy"
>
<template #placeholder>
<div class="image-slot">正在加载图片</div>
</template>
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
<el-image v-else-if="slotProp.item.progress === 101">
<template #error>
<div class="image-slot">
<div class="err-msg-container">
<div class="title">任务失败</div>
<div class="opt">
<el-popover title="错误详情" trigger="click" :width="250" :content="slotProp.item['err_msg']" placement="top">
<template #reference>
<el-button type="info">详情</el-button>
</template>
</el-popover>
<el-button type="danger" @click="removeImage(slotProp.item)">删除</el-button>
<template #default="{ item, url }">
<div
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
>
<div class="overflow-hidden rounded-lg">
<LazyImg
:url="url"
v-if="item.progress === 100"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
@click="previewImg(item)"
/>
<el-image v-else-if="item.progress === 101">
<template #error>
<div class="image-slot">
<div class="err-msg-container">
<div class="title">任务失败</div>
<div class="opt">
<el-popover
title="错误详情"
trigger="click"
:width="250"
:content="item['err_msg']"
placement="top"
>
<template #reference>
<el-button type="info">详情</el-button>
</template>
</el-popover>
<el-button type="danger" @click="removeImage(item)"
>删除</el-button
>
</div>
</div>
</div>
</template>
</el-image>
</div>
<div
class="px-4 pt-2 pb-4 border-t border-t-gray-800"
v-if="item.progress === 100"
>
<div
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
>
<div class="flex">
<el-tooltip content="取消分享" placement="top" v-if="item.publish">
<el-button
type="warning"
@click="publishImage(item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
</el-tooltip>
<el-tooltip content="分享" placement="top" v-else>
<el-button
type="success"
@click="publishImage(item, true)"
circle
>
<i class="iconfont icon-share-bold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="复制提示词" placement="top">
<el-button
type="info"
circle
class="copy-prompt"
:data-clipboard-text="item.prompt"
>
<i class="iconfont icon-file"></i>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button
type="danger"
:icon="Delete"
@click="removeImage(item)"
circle
/>
</el-tooltip>
</div>
</template>
</el-image>
<el-image v-else>
<template #error>
<div class="image-slot">
<i class="iconfont icon-loading"></i>
<span>正在下载图片</span>
</div>
</template>
</el-image>
<div class="remove">
<el-tooltip content="删除" placement="top">
<el-button type="danger" :icon="Delete" @click="removeImage(slotProp.item)" circle />
</el-tooltip>
<el-tooltip content="取消分享" placement="top" v-if="slotProp.item.publish">
<el-button type="warning" @click="publishImage(slotProp.item, false)" circle>
<i class="iconfont icon-cancel-share"></i>
</el-button>
</el-tooltip>
<el-tooltip content="分享" placement="top" v-else>
<el-button type="success" @click="publishImage(slotProp.item, true)" circle>
<i class="iconfont icon-share-bold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="复制提示词" placement="top">
<el-button type="info" circle class="copy-prompt" :data-clipboard-text="slotProp.item.prompt">
<i class="iconfont icon-file"></i>
</el-button>
</el-tooltip>
</div>
</div>
</div>
</template>
</Waterfall>
<template #footer>
<div class="no-more-data">
<span>没有更多数据了</span>
<div class="flex justify-center py-10">
<img
:src="waterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]"
v-if="loading"
/>
<div v-else>
<button
class="px-5 py-2 rounded-full bg-purple-700 text-md text-white cursor-pointer hover:bg-purple-800 transition-all duration-300"
@click="fetchFinishJobs"
v-if="!isOver"
>
加载更多
</button>
<div class="no-more-data" v-else>
<span class="text-gray-500 mr-2">没有更多数据了</span>
<i class="iconfont icon-face"></i>
</div>
</template>
</v3-waterfall>
</div>
</div>
</div>
<el-empty :image-size="100" :image="nodata" description="暂无记录" v-else />
</div>
</template>
<!-- end finish job list-->
</div>
</div>
@@ -214,7 +276,7 @@
<el-image-viewer
@close="
() => {
previewURL = '';
previewURL = ''
}
"
v-if="previewURL !== ''"
@@ -224,280 +286,306 @@
</template>
<script setup>
import nodata from "@/assets/img/no-data.png";
import nodata from '@/assets/img/no-data.png'
import { nextTick, onMounted, onUnmounted, ref } from "vue";
import { Delete, InfoFilled, Picture } from "@element-plus/icons-vue";
import { httpGet, httpPost } from "@/utils/http";
import { ElMessage, ElMessageBox } from "element-plus";
import Clipboard from "clipboard";
import { checkSession, getSystemInfo } from "@/store/cache";
import { useSharedStore } from "@/store/sharedata";
import TaskList from "@/components/TaskList.vue";
import BackTop from "@/components/BackTop.vue";
import { showMessageError, showMessageOK } from "@/utils/dialog";
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { Delete, InfoFilled, Picture } from '@element-plus/icons-vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import Clipboard from 'clipboard'
import { checkSession, getSystemInfo } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import TaskList from '@/components/TaskList.vue'
import BackTop from '@/components/BackTop.vue'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
const listBoxHeight = ref(0);
const listBoxHeight = ref(0)
// const paramBoxHeight = ref(0)
const isLogin = ref(false);
const loading = ref(true);
const colWidth = ref(220);
const isOver = ref(false);
const previewURL = ref("");
const store = useSharedStore();
const models = ref([]);
const isLogin = ref(false)
const loading = ref(true)
const isOver = ref(false)
const previewURL = ref('')
const store = useSharedStore()
const models = ref([])
const waterfallOptions = store.waterfallOptions
const resizeElement = function () {
listBoxHeight.value = window.innerHeight - 58;
// paramBoxHeight.value = window.innerHeight - 110
};
resizeElement();
window.onresize = () => {
resizeElement();
};
const qualities = [
{ name: "标准", value: "standard" },
{ name: "高清", value: "hd" },
];
const dalleSizes = ["1024x1024", "1792x1024", "1024x1792"];
const fluxSizes = ["1024x1024", "1024x768", "768x1024", "1280x960", "960x1280", "1366x768", "768x1366"];
const sizes = ref(dalleSizes);
const styles = [
{ name: "生动", value: "vivid" },
{ name: "自然", value: "natural" },
];
const params = ref({
quality: "standard",
size: "1024x1024",
style: "vivid",
prompt: "",
});
listBoxHeight.value = window.innerHeight - 58
}
const finishedJobs = ref([]);
const runningJobs = ref([]);
const allowPulling = ref(true); // 是否允许轮询
const tastPullHandler = ref(null);
const power = ref(0);
const dallPower = ref(0); // 画一张 SD 图片消耗算力
const clipboard = ref(null);
const userId = ref(0);
const selectedModel = ref(null);
resizeElement()
window.onresize = () => {
resizeElement()
}
const qualities = [
{ name: '标准', value: 'standard' },
{ name: '高清', value: 'hd' },
]
const dalleSizes = ['1024x1024', '1792x1024', '1024x1792']
const fluxSizes = ['1024x1024', '1152x896', '896x1152', '1280x960', '1024x576']
const sizes = ref(dalleSizes)
const styles = [
{ name: '生动', value: 'vivid' },
{ name: '自然', value: 'natural' },
]
const params = ref({
quality: 'standard',
size: '1024x1024',
style: 'vivid',
prompt: '',
})
const finishedJobs = ref([])
const runningJobs = ref([])
const allowPulling = ref(true) // 是否允许轮询
const downloadPulling = ref(false) // 下载轮询
const tastPullHandler = ref(null)
const downloadPullHandler = ref(null)
const power = ref(0)
const dallPower = ref(0) // 画一张 SD 图片消耗算力
const clipboard = ref(null)
const userId = ref(0)
const selectedModel = ref(null)
onMounted(() => {
initData();
clipboard.value = new Clipboard(".copy-prompt");
clipboard.value.on("success", () => {
showMessageOK("复制成功!");
});
initData()
clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on('success', () => {
showMessageOK('复制成功!')
})
clipboard.value.on("error", () => {
showMessageError("复制失败!");
});
clipboard.value.on('error', () => {
showMessageError('复制失败!')
})
getSystemInfo()
.then((res) => {
dallPower.value = res.data["dall_power"];
dallPower.value = res.data['dall_power']
})
.catch((e) => {
showMessageError("获取系统配置失败:" + e.message);
});
showMessageError('获取系统配置失败:' + e.message)
})
// 获取模型列表
httpGet("/api/dall/models")
httpGet('/api/dall/models')
.then((res) => {
models.value = res.data;
selectedModel.value = models.value[0];
params.value.model_id = selectedModel.value.id;
changeModel(selectedModel.value);
models.value = res.data
selectedModel.value = models.value[0]
params.value.model_id = selectedModel.value.id
changeModel(selectedModel.value)
})
.catch((e) => {
showMessageError("获取模型列表失败:" + e.message);
});
});
showMessageError('获取模型列表失败:' + e.message)
})
})
onUnmounted(() => {
clipboard.value.destroy();
clipboard.value.destroy()
if (tastPullHandler.value) {
clearInterval(tastPullHandler.value);
clearInterval(tastPullHandler.value)
}
});
if (downloadPullHandler.value) {
clearInterval(downloadPullHandler.value)
}
})
const initData = () => {
checkSession()
.then((user) => {
power.value = user["power"];
userId.value = user.id;
isLogin.value = true;
page.value = 0;
fetchRunningJobs();
fetchFinishJobs();
power.value = user['power']
userId.value = user.id
isLogin.value = true
page.value = 0
fetchRunningJobs()
fetchFinishJobs()
// 轮询运行中任务
tastPullHandler.value = setInterval(() => {
if (allowPulling.value) {
fetchRunningJobs();
fetchRunningJobs()
}
}, 5000);
}, 5000)
// 图片下载轮询
downloadPullHandler.value = setInterval(() => {
if (downloadPulling.value) {
page.value = 0
fetchFinishJobs()
}
}, 5000)
})
.catch(() => {});
};
.catch(() => {})
}
const fetchRunningJobs = () => {
if (!isLogin.value) {
return;
return
}
// 获取运行中的任务
httpGet(`/api/dall/jobs?finish=false`)
.then((res) => {
// 如果任务有更新,则更新已完成任务列表
if (res.data.items && res.data.items.length !== runningJobs.value.length) {
page.value = 0;
fetchFinishJobs();
page.value = 0
fetchFinishJobs()
}
if (res.data.items.length > 0) {
runningJobs.value = res.data.items;
runningJobs.value = res.data.items
} else {
allowPulling.value = false;
runningJobs.value = [];
allowPulling.value = false
runningJobs.value = []
}
})
.catch((e) => {
ElMessage.error("获取任务失败:" + e.message);
});
};
ElMessage.error('获取任务失败:' + e.message)
})
}
const page = ref(1);
const pageSize = ref(15);
const page = ref(1)
const pageSize = ref(15)
// 获取已完成的任务
const fetchFinishJobs = () => {
if (!isLogin.value) {
return;
return
}
loading.value = true;
page.value = page.value + 1;
loading.value = true
page.value = page.value + 1
httpGet(`/api/dall/jobs?finish=true&page=${page.value}&page_size=${pageSize.value}`)
.then((res) => {
if (res.data.items.length < pageSize.value) {
isOver.value = true;
isOver.value = true
loading.value = false
}
const imageList = res.data.items;
const imageList = res.data.items
let needPulling = false
for (let i = 0; i < imageList.length; i++) {
imageList[i]["img_thumb"] = imageList[i]["img_url"] + "?imageView2/4/w/300/h/0/q/75";
if (imageList[i]['img_url']) {
imageList[i]['img_thumb'] = imageList[i]['img_url'] + '?imageView2/4/w/300/h/0/q/75'
} else if (imageList[i].progress === 100) {
needPulling = true
imageList[i]['img_thumb'] = waterfallOptions.loadProps.loading
}
}
// 如果当前是第一页,则开启图片下载轮询
if (page.value === 1) {
finishedJobs.value = imageList;
} else {
finishedJobs.value = finishedJobs.value.concat(imageList);
downloadPulling.value = needPulling
}
loading.value = false;
if (page.value === 1) {
finishedJobs.value = imageList
} else {
finishedJobs.value = finishedJobs.value.concat(imageList)
}
})
.catch((e) => {
ElMessage.error("获取任务失败:" + e.message);
});
};
ElMessage.error('获取任务失败:' + e.message)
loading.value = false
})
}
// 创建绘图任务
const promptRef = ref(null);
const promptRef = ref(null)
const generate = () => {
if (params.value.prompt === "") {
promptRef.value.focus();
return ElMessage.error("请输入绘画提示词!");
if (params.value.prompt === '') {
promptRef.value.focus()
return ElMessage.error('请输入绘画提示词!')
}
if (!isLogin.value) {
store.setShowLoginDialog(true);
return;
store.setShowLoginDialog(true)
return
}
httpPost("/api/dall/image", params.value)
httpPost('/api/dall/image', params.value)
.then(() => {
ElMessage.success("任务执行成功!");
power.value -= dallPower.value;
ElMessage.success('任务执行成功!')
power.value -= dallPower.value
// 追加任务列表
runningJobs.value.push({
prompt: params.value.prompt,
progress: 0,
});
allowPulling.value = true;
})
allowPulling.value = true
isOver.value = false
})
.catch((e) => {
ElMessage.error("任务执行失败:" + e.message);
});
};
ElMessage.error('任务执行失败:' + e.message)
})
}
const removeImage = (item) => {
ElMessageBox.confirm("此操作将会删除任务和图片,继续操作码?", "删除提示", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
ElMessageBox.confirm('此操作将会删除任务和图片,继续操作码?', '删除提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
httpGet("/api/dall/remove", { id: item.id })
httpGet('/api/dall/remove', { id: item.id })
.then(() => {
ElMessage.success("任务删除成功");
page.value = 0;
isOver.value = false;
fetchFinishJobs();
ElMessage.success('任务删除成功')
page.value = 0
isOver.value = false
fetchFinishJobs()
})
.catch((e) => {
ElMessage.error("任务删除失败:" + e.message);
});
ElMessage.error('任务删除失败:' + e.message)
})
})
.catch(() => {});
};
.catch(() => {})
}
const previewImg = (item) => {
previewURL.value = item.img_url;
};
previewURL.value = item.img_url
}
// 发布图片到作品墙
const publishImage = (item, action) => {
let text = "图片发布";
let text = '图片发布'
if (action === false) {
text = "取消发布";
text = '取消发布'
}
httpGet("/api/dall/publish", { id: item.id, action: action })
httpGet('/api/dall/publish', { id: item.id, action: action })
.then(() => {
ElMessage.success(text + "成功");
item.publish = action;
page.value = 0;
isOver.value = false;
ElMessage.success(text + '成功')
item.publish = action
page.value = 0
isOver.value = false
})
.catch((e) => {
ElMessage.error(text + "失败:" + e.message);
});
};
ElMessage.error(text + '失败:' + e.message)
})
}
const isGenerating = ref(false);
const isGenerating = ref(false)
const generatePrompt = () => {
if (params.value.prompt === "") {
return showMessageError("请输入原始提示词");
if (params.value.prompt === '') {
return showMessageError('请输入原始提示词')
}
isGenerating.value = true;
httpPost("/api/prompt/image", { prompt: params.value.prompt })
isGenerating.value = true
httpPost('/api/prompt/image', { prompt: params.value.prompt })
.then((res) => {
params.value.prompt = res.data;
isGenerating.value = false;
params.value.prompt = res.data
isGenerating.value = false
})
.catch((e) => {
showMessageError("生成提示词失败:" + e.message);
isGenerating.value = false;
});
};
showMessageError('生成提示词失败:' + e.message)
isGenerating.value = false
})
}
const changeModel = (model) => {
if (model.value.startsWith("dall")) {
sizes.value = dalleSizes;
if (model.value.startsWith('dall')) {
sizes.value = dalleSizes
} else {
sizes.value = fluxSizes;
sizes.value = fluxSizes
}
params.value.model_id = selectedModel.value.id;
};
params.value.model_id = selectedModel.value.id
}
</script>
<style lang="stylus">
@import "@/assets/css/image-dall.styl"
@import "@/assets/css/custom-scroll.styl"
@import '@/assets/css/image-dall.styl';
@import '@/assets/css/custom-scroll.styl';
</style>

View File

@@ -58,7 +58,9 @@
<i class="iconfont" :class="item.icon"></i>
</span>
<el-image :src="item.icon" style="width: 20px; height: 20px" v-else />
<span :class="item.url === curPath ? 'title active' : 'title'">{{ item.name }}</span>
<span :class="item.url === curPath ? 'title active' : 'title'">{{
item.name
}}</span>
</a>
</li>
</ul>
@@ -77,7 +79,7 @@
<el-icon>
<UserFilled />
</el-icon>
<span class="username title">{{ loginUser.nickname }}</span>
<span class="username title">账户信息</span>
</div>
</li>
<li v-if="!license.de_copy">
@@ -108,7 +110,12 @@
</div>
<el-scrollbar class="right-main">
<div class="topheader" v-if="loginUser.id === undefined || !loginUser.id">
<el-button @click="router.push('/login')" class="btn-go animate__animated animate__pulse animate__infinite" round>登录</el-button>
<el-button
@click="router.push('/login')"
class="btn-go animate__animated animate__pulse animate__infinite"
round
>登录</el-button
>
</div>
<div class="content custom-scroll">
<router-view :key="routerViewKey" v-slot="{ Component }">
@@ -123,7 +130,9 @@
<el-dialog v-model="showLoginDialog" width="500px" @close="store.setShowLoginDialog(false)">
<template #header>
<div class="text-center text-xl" style="color: var(--theme-text-color-primary)">登录后解锁功能</div>
<div class="text-center text-xl" style="color: var(--theme-text-color-primary)">
登录后解锁功能
</div>
</template>
<div class="p-4 pt-2 pb-2">
<LoginDialog @success="loginSuccess" @hide="store.setShowLoginDialog(false)" />
@@ -133,33 +142,33 @@
</template>
<script setup>
import { UserFilled } from "@element-plus/icons-vue";
import ThemeChange from "@/components/ThemeChange.vue";
import { useRouter } from "vue-router";
import { computed, onMounted, ref, watch } from "vue";
import { httpGet } from "@/utils/http";
import { ElMessage } from "element-plus";
import { checkSession, getLicenseInfo, getSystemInfo } from "@/store/cache";
import { removeUserToken } from "@/store/session";
import { useSharedStore } from "@/store/sharedata";
import ConfigDialog from "@/components/UserInfoDialog.vue";
import { showMessageError } from "@/utils/dialog";
import LoginDialog from "@/components/LoginDialog.vue";
import { UserFilled } from '@element-plus/icons-vue'
import ThemeChange from '@/components/ThemeChange.vue'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref, watch } from 'vue'
import { httpGet } from '@/utils/http'
import { ElMessage } from 'element-plus'
import { checkSession, getLicenseInfo, getSystemInfo } from '@/store/cache'
import { removeUserToken } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import ConfigDialog from '@/components/UserInfoDialog.vue'
import { showMessageError } from '@/utils/dialog'
import LoginDialog from '@/components/LoginDialog.vue'
const router = useRouter();
const logo = ref("");
const mainNavs = ref([]);
const moreNavs = ref([]);
const curPath = ref();
const router = useRouter()
const logo = ref('')
const mainNavs = ref([])
const moreNavs = ref([])
const curPath = ref()
const title = ref("");
const store = useSharedStore();
const loginUser = ref({});
const routerViewKey = ref(0);
const showConfigDialog = ref(false);
const license = ref({ de_copy: true });
const showLoginDialog = ref(false);
const githubURL = ref(process.env.VUE_APP_GITHUB_URL);
const title = ref('')
const store = useSharedStore()
const loginUser = ref({})
const routerViewKey = ref(0)
const showConfigDialog = ref(false)
const license = ref({ de_copy: true })
const showLoginDialog = ref(false)
const githubURL = ref(process.env.VUE_APP_GITHUB_URL)
/**
* 从路径名中提取第一个路径段
@@ -167,123 +176,123 @@ const githubURL = ref(process.env.VUE_APP_GITHUB_URL);
* @returns 第一个路径段(不含斜杠),例如 'chat',如果不存在则返回 null
*/
const extractFirstSegment = (pathname) => {
const segments = pathname.split("/").filter((segment) => segment.length > 0);
return segments.length > 0 ? segments[0] : null;
};
const segments = pathname.split('/').filter((segment) => segment.length > 0)
return segments.length > 0 ? segments[0] : null
}
const getFirstPathSegment = (url) => {
try {
// 尝试使用 URL 构造函数解析完整的 URL
const parsedUrl = new URL(url);
return extractFirstSegment(parsedUrl.pathname);
const parsedUrl = new URL(url)
return extractFirstSegment(parsedUrl.pathname)
} catch (error) {
// 如果解析失败,假设是相对路径,使用当前窗口的位置作为基准
if (typeof window !== "undefined") {
const parsedUrl = new URL(url, window.location.origin);
return extractFirstSegment(parsedUrl.pathname);
if (typeof window !== 'undefined') {
const parsedUrl = new URL(url, window.location.origin)
return extractFirstSegment(parsedUrl.pathname)
}
// 如果无法解析,返回 null
return null;
return null
}
};
}
const stars = computed(() => {
return 1000;
});
return 1000
})
watch(
() => store.showLoginDialog,
(newValue) => {
showLoginDialog.value = newValue;
showLoginDialog.value = newValue
}
);
)
// 监听路由变化;
router.beforeEach((to, from, next) => {
curPath.value = to.path;
next();
});
curPath.value = to.path
next()
})
if (curPath.value === "/external") {
curPath.value = router.currentRoute.value.query.url;
if (curPath.value === '/external') {
curPath.value = router.currentRoute.value.query.url
}
const changeNav = (item) => {
curPath.value = item.url;
if (item.url.indexOf("http") !== -1) {
curPath.value = item.url
if (item.url.indexOf('http') !== -1) {
// 外部链接
router.push({ path: "/external", query: { url: item.url, title: item.name } });
router.push({ path: '/external', query: { url: item.url, title: item.name } })
} else {
// 路由切换,确保路径变化
if (router.currentRoute.value.path !== item.url) {
router.push(item.url).then(() => {
// 刷新 `routerViewKey` 触发视图重新渲染
routerViewKey.value += 1;
});
routerViewKey.value += 1
})
}
}
};
}
onMounted(() => {
curPath.value = router.currentRoute.value.path;
curPath.value = router.currentRoute.value.path
getSystemInfo()
.then((res) => {
logo.value = res.data.logo;
title.value = res.data.title;
logo.value = res.data.logo
title.value = res.data.title
})
.catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message);
});
ElMessage.error('获取系统配置失败:' + e.message)
})
// 获取菜单
httpGet("/api/menu/list")
httpGet('/api/menu/list')
.then((res) => {
mainNavs.value = res.data;
mainNavs.value = res.data
// 根据窗口的高度计算应该显示多少菜单
const rows = Math.floor((window.innerHeight - 100) / 90);
const rows = Math.floor((window.innerHeight - 100) / 90)
if (res.data.length > rows) {
mainNavs.value = res.data.slice(0, rows);
moreNavs.value = res.data.slice(rows);
mainNavs.value = res.data.slice(0, rows)
moreNavs.value = res.data.slice(rows)
}
})
.catch((e) => {
ElMessage.error("获取系统菜单失败:" + e.message);
});
ElMessage.error('获取系统菜单失败:' + e.message)
})
getLicenseInfo()
.then((res) => {
license.value = res.data;
license.value = res.data
})
.catch((e) => {
license.value = { de_copy: false };
showMessageError("获取 License 配置:" + e.message);
});
curPath.value = "/" + getFirstPathSegment(window.location.href);
init();
});
license.value = { de_copy: false }
showMessageError('获取 License 配置:' + e.message)
})
curPath.value = '/' + getFirstPathSegment(window.location.href)
init()
})
const init = () => {
checkSession()
.then((user) => {
loginUser.value = user;
loginUser.value = user
})
.catch(() => {});
};
.catch(() => {})
}
const logout = function () {
httpGet("/api/user/logout")
httpGet('/api/user/logout')
.then(() => {
removeUserToken();
router.push("/login");
removeUserToken()
router.push('/login')
})
.catch(() => {
ElMessage.error("注销失败!");
});
};
ElMessage.error('注销失败!')
})
}
const loginSuccess = () => {
init();
store.setShowLoginDialog(false);
init()
store.setShowLoginDialog(false)
// 刷新组件
routerViewKey.value += 1;
};
routerViewKey.value += 1
}
</script>
<style lang="stylus" scoped>

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,18 @@
<template #default>
<div class="form-item-inner">
<el-select v-model="params.sampler" style="width: 150px">
<el-option v-for="item in samplers" :label="item" :value="item" :key="item" />
<el-option
v-for="item in samplers"
:label="item"
:value="item"
:key="item"
/>
</el-select>
<el-tooltip content="出图效果比较好的一般是 Euler 和 DPM 系列算法" raw-content placement="right">
<el-tooltip
content="出图效果比较好的一般是 Euler 和 DPM 系列算法"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -29,7 +38,12 @@
<template #default>
<div class="form-item-inner">
<el-select v-model="params.scheduler" style="width: 150px">
<el-option v-for="item in schedulers" :label="item" :value="item" :key="item" />
<el-option
v-for="item in schedulers"
:label="item"
:value="item"
:key="item"
/>
</el-select>
<el-tooltip content="推荐自动或者 Karras" raw-content placement="right">
<el-icon class="info-icon">
@@ -63,7 +77,11 @@
<template #default>
<div class="form-item-inner">
<el-input v-model.number="params.steps" />
<el-tooltip content="值越大则代表细节越多,同时也意味着出图速度越慢" raw-content placement="right">
<el-tooltip
content="值越大则代表细节越多,同时也意味着出图速度越慢"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -78,7 +96,11 @@
<template #default>
<div class="form-item-inner">
<el-input v-model.number="params.cfg_scale" />
<el-tooltip content="提示词引导系数,图像在多大程度上服从提示词<br/> 较低值会产生更有创意的结果" raw-content placement="right">
<el-tooltip
content="提示词引导系数,图像在多大程度上服从提示词<br/> 较低值会产生更有创意的结果"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -93,7 +115,11 @@
<template #default>
<div class="form-item-inner">
<el-input v-model.number="params.seed" />
<el-tooltip content="随机数种子,相同的种子会得到相同的结果<br/> 设置为 -1 则每次随机生成种子" raw-content placement="right">
<el-tooltip
content="随机数种子,相同的种子会得到相同的结果<br/> 设置为 -1 则每次随机生成种子"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -113,8 +139,16 @@
<el-form-item label="高清修复">
<template #default>
<div class="form-item-inner">
<el-switch v-model="params.hd_fix" style="--el-switch-on-color: #47fff1" size="large" />
<el-tooltip content="先以较小的分辨率生成图像,接着方法图像<br />然后在不更改构图的情况下再修改细节" raw-content placement="right">
<el-switch
v-model="params.hd_fix"
style="--el-switch-on-color: #47fff1"
size="large"
/>
<el-tooltip
content="先以较小的分辨率生成图像,接着方法图像<br />然后在不更改构图的情况下再修改细节"
raw-content
placement="right"
>
<el-icon style="margin-left: 10px; top: 12px">
<InfoFilled />
</el-icon>
@@ -129,8 +163,17 @@
<el-form-item label="重绘幅度">
<template #default>
<div class="form-item-inner">
<el-slider v-model.number="params.hd_redraw_rate" :max="1" :step="0.1" style="width: 180px; --el-slider-main-bg-color: #47fff1" />
<el-tooltip content="决定算法对图像内容的影响程度<br />较大的值将得到越有创意的图像" raw-content placement="right">
<el-slider
v-model.number="params.hd_redraw_rate"
:max="1"
:step="0.1"
style="width: 180px; --el-slider-main-bg-color: #47fff1"
/>
<el-tooltip
content="决定算法对图像内容的影响程度<br />较大的值将得到越有创意的图像"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -145,9 +188,18 @@
<template #default>
<div class="form-item-inner">
<el-select v-model="params.hd_scale_alg" style="width: 176px">
<el-option v-for="item in scaleAlg" :label="item" :value="item" :key="item" />
<el-option
v-for="item in scaleAlg"
:label="item"
:value="item"
:key="item"
/>
</el-select>
<el-tooltip content="高清修复放大算法主流算法有Latent和ESRGAN_4x" raw-content placement="right">
<el-tooltip
content="高清修复放大算法主流算法有Latent和ESRGAN_4x"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -162,7 +214,11 @@
<template #default>
<div class="form-item-inner">
<el-input v-model.number="params.hd_scale" />
<el-tooltip content="随机数种子,相同的种子会得到相同的结果<br/> 设置为 -1 则每次随机生成种子" raw-content placement="right">
<el-tooltip
content="随机数种子,相同的种子会得到相同的结果<br/> 设置为 -1 则每次随机生成种子"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -177,7 +233,11 @@
<template #default>
<div class="form-item-inner">
<el-input v-model.number="params.hd_steps" />
<el-tooltip content="重绘迭代步数如果设置为0则设置跟原图相同的迭代步数" raw-content placement="right">
<el-tooltip
content="重绘迭代步数如果设置为0则设置跟原图相同的迭代步数"
raw-content
placement="right"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
@@ -201,7 +261,13 @@
</div>
<el-row class="text-info">
<el-button class="generate-btn" size="small" @click="generatePrompt" color="#5865f2" :disabled="isGenerating">
<el-button
class="generate-btn"
size="small"
@click="generatePrompt"
color="#5865f2"
:disabled="isGenerating"
>
<i class="iconfont icon-chuangzuo" style="margin-right: 5px"></i>
<span>生成专业绘画指令</span>
</el-button>
@@ -216,7 +282,12 @@
</el-tooltip>
</div>
<div class="param-line">
<el-input v-model="params.neg_prompt" :autosize="{ minRows: 4, maxRows: 6 }" type="textarea" placeholder="反向提示词" />
<el-input
v-model="params.neg_prompt"
:autosize="{ minRows: 4, maxRows: 6 }"
type="textarea"
placeholder="反向提示词"
/>
</div>
<div class="text-info">
@@ -243,61 +314,138 @@
<task-list :list="runningJobs" />
<template v-if="finishedJobs.length > 0">
<h2 class="text-xl">创作记录</h2>
<div class="finish-job-list">
<div class="finish-job-list mt-3">
<div v-if="finishedJobs.length > 0">
<v3-waterfall
id="waterfall"
<Waterfall
:list="finishedJobs"
srcKey="img_thumb"
:gap="20"
:bottomGap="-10"
:colWidth="colWidth"
:distanceToScroll="100"
:isLoading="loading"
:isOver="isOver"
@scrollReachBottom="fetchFinishJobs()"
:row-key="waterfallOptions.rowKey"
:gutter="waterfallOptions.gutter"
:has-around-gutter="waterfallOptions.hasAroundGutter"
:width="waterfallOptions.width"
:breakpoints="waterfallOptions.breakpoints"
:img-selector="waterfallOptions.imgSelector"
:background-color="waterfallOptions.backgroundColor"
:animation-effect="waterfallOptions.animationEffect"
:animation-duration="waterfallOptions.animationDuration"
:animation-delay="waterfallOptions.animationDelay"
:animation-cancel="waterfallOptions.animationCancel"
:lazyload="waterfallOptions.lazyload"
:load-props="waterfallOptions.loadProps"
:cross-origin="waterfallOptions.crossOrigin"
:align="waterfallOptions.align"
:is-loading="loading"
:is-over="isOver"
@afterRender="loading = false"
>
<template #default="slotProp">
<div class="job-item animate">
<el-image v-if="slotProp.item.progress === 101">
<template #error>
<div class="image-slot">
<div class="err-msg-container">
<div class="title">任务失败</div>
<div class="opt">
<el-popover title="错误详情" trigger="click" :width="250" :content="slotProp.item['err_msg']" placement="top">
<template #reference>
<el-button type="info">详情</el-button>
</template>
</el-popover>
<el-button type="danger" @click="removeImage(slotProp.item)">删除</el-button>
<template #default="{ item, url }">
<div
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
>
<div class="overflow-hidden rounded-lg">
<LazyImg
:url="url"
v-if="item.progress === 100"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
@click="showTask(item)"
/>
<el-image v-else-if="item.progress === 101">
<template #error>
<div class="image-slot">
<div class="err-msg-container">
<div class="title">任务失败</div>
<div class="opt">
<el-popover
title="错误详情"
trigger="click"
:width="250"
:content="item['err_msg']"
placement="top"
>
<template #reference>
<el-button type="info">详情</el-button>
</template>
</el-popover>
<el-button type="danger" @click="removeImage(item)"
>删除</el-button
>
</div>
</div>
</div>
</template>
</el-image>
</div>
<div
class="px-4 pt-2 pb-4 border-t border-t-gray-800"
v-if="item.progress === 100"
>
<div
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
>
<div class="flex">
<el-tooltip content="取消分享" placement="top" v-if="item.publish">
<el-button
type="warning"
@click="publishImage(item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
</el-tooltip>
<el-tooltip content="分享" placement="top" v-else>
<el-button
type="success"
@click="publishImage(item, true)"
circle
>
<i class="iconfont icon-share-bold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="复制提示词" placement="top">
<el-button
type="info"
circle
class="copy-prompt"
:data-clipboard-text="item.prompt"
>
<i class="iconfont icon-file"></i>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button
type="danger"
:icon="Delete"
@click="removeImage(item)"
circle
/>
</el-tooltip>
</div>
</template>
</el-image>
<div v-else>
<el-image :src="slotProp.item['img_thumb']" @click="showTask(slotProp.item)" fit="cover" loading="lazy" />
<div class="remove">
<el-button type="danger" :icon="Delete" @click="removeImage(slotProp.item)" circle />
<el-button type="warning" v-if="slotProp.item.publish" @click="publishImage(slotProp.item, false)" circle>
<i class="iconfont icon-cancel-share"></i>
</el-button>
<el-button type="success" v-else @click="publishImage(slotProp.item, true)" circle>
<i class="iconfont icon-share-bold"></i>
</el-button>
</div>
</div>
</div>
</template>
</Waterfall>
<template #footer>
<div class="no-more-data">
<span>没有更多数据了</span>
<div class="flex justify-center py-10">
<img
:src="waterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]"
v-if="loading"
/>
<div v-else>
<button
class="px-5 py-2 rounded-full bg-purple-700 text-md text-white cursor-pointer hover:bg-purple-800 transition-all duration-300"
@click="fetchFinishJobs"
v-if="!isOver"
>
加载更多
</button>
<div class="no-more-data" v-else>
<span class="text-gray-500 mr-2">没有更多数据了</span>
<i class="iconfont icon-face"></i>
</div>
</template>
</v3-waterfall>
</div>
</div>
</div>
<el-empty :image-size="100" v-else :image="nodata" description="暂无记录" />
</div>
@@ -312,48 +460,63 @@
</div>
<!-- 任务详情弹框 -->
<sd-task-view v-model="showTaskDialog" :data="item" @drawSame="copyParams" @close="showTaskDialog = false" />
<sd-task-view
v-model="showTaskDialog"
:data="item"
@drawSame="copyParams"
@close="showTaskDialog = false"
/>
</div>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref } from "vue";
import { Delete, DocumentCopy, InfoFilled, Orange } from "@element-plus/icons-vue";
import nodata from "@/assets/img/no-data.png";
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { Delete, DocumentCopy, InfoFilled, Orange } from '@element-plus/icons-vue'
import nodata from '@/assets/img/no-data.png'
import { httpGet, httpPost } from "@/utils/http";
import { ElMessage, ElMessageBox } from "element-plus";
import Clipboard from "clipboard";
import { checkSession, getSystemInfo } from "@/store/cache";
import { useRouter } from "vue-router";
import { getSessionId } from "@/store/session";
import { useSharedStore } from "@/store/sharedata";
import TaskList from "@/components/TaskList.vue";
import BackTop from "@/components/BackTop.vue";
import { showMessageError } from "@/utils/dialog";
import SdTaskView from "@/components/SdTaskView.vue";
const listBoxHeight = ref(0);
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import Clipboard from 'clipboard'
import { checkSession, getSystemInfo } from '@/store/cache'
import { useRouter } from 'vue-router'
import { getSessionId } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import TaskList from '@/components/TaskList.vue'
import BackTop from '@/components/BackTop.vue'
import { showMessageError } from '@/utils/dialog'
import SdTaskView from '@/components/SdTaskView.vue'
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
const listBoxHeight = ref(0)
// const paramBoxHeight = ref(0)
const fullImgHeight = ref(window.innerHeight - 60);
const showTaskDialog = ref(false);
const item = ref({});
const isLogin = ref(false);
const loading = ref(true);
const colWidth = ref(220);
const store = useSharedStore();
const showTaskDialog = ref(false)
const item = ref({})
const isLogin = ref(false)
const loading = ref(true)
const store = useSharedStore()
const waterfallOptions = store.waterfallOptions
const resizeElement = function () {
listBoxHeight.value = window.innerHeight - 80;
listBoxHeight.value = window.innerHeight - 80
// paramBoxHeight.value = window.innerHeight - 200
};
resizeElement();
}
resizeElement()
window.onresize = () => {
resizeElement();
};
const samplers = ["Euler a", "DPM++ 2S a", "DPM++ 2M", "DPM++ SDE", "DPM++ 2M SDE", "UniPC", "Restart"];
const schedulers = ["Automatic", "Karras", "Exponential", "Uniform"];
const scaleAlg = ["Latent", "ESRGAN_4x", "R-ESRGAN 4x+", "SwinIR_4x", "LDSR"];
resizeElement()
}
const samplers = [
'Euler a',
'DPM++ 2S a',
'DPM++ 2M',
'DPM++ SDE',
'DPM++ 2M SDE',
'UniPC',
'Restart',
]
const schedulers = ['Automatic', 'Karras', 'Exponential', 'Uniform']
const scaleAlg = ['Latent', 'ESRGAN_4x', 'R-ESRGAN 4x+', 'SwinIR_4x', 'LDSR']
const params = ref({
width: 1024,
height: 1024,
@@ -367,227 +530,229 @@ const params = ref({
hd_scale: 2,
hd_scale_alg: scaleAlg[0],
hd_steps: 0,
prompt: "",
neg_prompt: "nsfw, paintings,low quality,easynegative,ng_deepnegative ,lowres,bad anatomy,bad hands,bad feet",
});
prompt: '',
neg_prompt:
'nsfw, paintings,low quality,easynegative,ng_deepnegative ,lowres,bad anatomy,bad hands,bad feet',
})
const runningJobs = ref([]);
const finishedJobs = ref([]);
const allowPulling = ref(true); // 是否允许轮询
const tastPullHandler = ref(null);
const router = useRouter();
const runningJobs = ref([])
const finishedJobs = ref([])
const allowPulling = ref(true) // 是否允许轮询
const tastPullHandler = ref(null)
const router = useRouter()
// 检查是否有画同款的参数
const _params = router.currentRoute.value.params["copyParams"];
const _params = router.currentRoute.value.params['copyParams']
if (_params) {
params.value = JSON.parse(_params);
params.value = JSON.parse(_params)
}
const power = ref(0);
const sdPower = ref(0); // 画一张 SD 图片消耗算力
const power = ref(0)
const sdPower = ref(0) // 画一张 SD 图片消耗算力
const userId = ref(0);
const clipboard = ref(null);
const userId = ref(0)
const clipboard = ref(null)
onMounted(() => {
initData();
clipboard.value = new Clipboard(".copy-prompt-sd");
clipboard.value.on("success", () => {
ElMessage.success("复制成功!");
});
initData()
clipboard.value = new Clipboard('.copy-prompt-sd')
clipboard.value.on('success', () => {
ElMessage.success('复制成功!')
})
clipboard.value.on("error", () => {
ElMessage.error("复制失败!");
});
clipboard.value.on('error', () => {
ElMessage.error('复制失败!')
})
getSystemInfo()
.then((res) => {
sdPower.value = res.data.sd_power;
params.value.neg_prompt = res.data.sd_neg_prompt;
sdPower.value = res.data.sd_power
params.value.neg_prompt = res.data.sd_neg_prompt
})
.catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message);
});
});
ElMessage.error('获取系统配置失败:' + e.message)
})
})
onUnmounted(() => {
clipboard.value.destroy();
clipboard.value.destroy()
if (tastPullHandler.value) {
clearInterval(tastPullHandler.value);
clearInterval(tastPullHandler.value)
}
});
})
const initData = () => {
checkSession()
.then((user) => {
power.value = user["power"];
userId.value = user.id;
isLogin.value = true;
page.value = 0;
fetchRunningJobs();
fetchFinishJobs();
power.value = user['power']
userId.value = user.id
isLogin.value = true
page.value = 0
fetchRunningJobs()
fetchFinishJobs()
tastPullHandler.value = setInterval(() => {
if (allowPulling.value) {
fetchRunningJobs();
fetchRunningJobs()
}
}, 5000);
}, 5000)
})
.catch(() => {});
};
.catch(() => {})
}
const fetchRunningJobs = () => {
if (!isLogin.value) {
return;
return
}
// 获取运行中的任务
httpGet(`/api/sd/jobs?finish=0`)
.then((res) => {
if (runningJobs.value.length !== res.data.items.length) {
page.value = 0;
fetchFinishJobs();
page.value = 0
fetchFinishJobs()
}
if (runningJobs.value.length === 0) {
allowPulling.value = false;
allowPulling.value = false
}
runningJobs.value = res.data.items;
runningJobs.value = res.data.items
})
.catch((e) => {
ElMessage.error("获取任务失败:" + e.message);
});
};
ElMessage.error('获取任务失败:' + e.message)
})
}
const page = ref(0);
const pageSize = ref(20);
const isOver = ref(false);
const page = ref(0)
const pageSize = ref(20)
const isOver = ref(false)
// 获取已完成的任务
const fetchFinishJobs = () => {
if (!isLogin.value || isOver.value === true) {
return;
return
}
loading.value = true;
page.value = page.value + 1;
loading.value = true
page.value = page.value + 1
httpGet(`/api/sd/jobs?finish=1&page=${page.value}&page_size=${pageSize.value}`)
.then((res) => {
if (res.data.items.length < pageSize.value) {
isOver.value = true;
isOver.value = true
loading.value = false
}
const imageList = res.data.items;
const imageList = res.data.items
for (let i = 0; i < imageList.length; i++) {
imageList[i]["img_thumb"] = imageList[i]["img_url"] + "?imageView2/4/w/300/h/0/q/75";
imageList[i]['img_thumb'] = imageList[i]['img_url'] + '?imageView2/4/w/300/h/0/q/75'
}
if (page.value === 1) {
finishedJobs.value = imageList;
finishedJobs.value = imageList
} else {
finishedJobs.value = finishedJobs.value.concat(imageList);
finishedJobs.value = finishedJobs.value.concat(imageList)
}
loading.value = false;
})
.catch((e) => {
ElMessage.error("获取任务失败:" + e.message);
});
};
ElMessage.error('获取任务失败:' + e.message)
loading.value = false
})
}
// 创建绘图任务
const promptRef = ref(null);
const promptRef = ref(null)
const generate = () => {
if (params.value.prompt === "") {
promptRef.value.focus();
return ElMessage.error("请输入绘画提示词!");
if (params.value.prompt === '') {
promptRef.value.focus()
return ElMessage.error('请输入绘画提示词!')
}
if (!isLogin.value) {
store.setShowLoginDialog(true);
return;
store.setShowLoginDialog(true)
return
}
if (!params.value.seed) {
params.value.seed = -1;
params.value.seed = -1
}
params.value.session_id = getSessionId();
httpPost("/api/sd/image", params.value)
params.value.session_id = getSessionId()
httpPost('/api/sd/image', params.value)
.then(() => {
ElMessage.success("绘画任务推送成功,请耐心等待任务执行...");
power.value -= sdPower.value;
allowPulling.value = true;
ElMessage.success('绘画任务推送成功,请耐心等待任务执行...')
power.value -= sdPower.value
allowPulling.value = true
runningJobs.value.push({
progress: 0,
});
})
isOver.value = false
})
.catch((e) => {
ElMessage.error("任务推送失败:" + e.message);
});
};
ElMessage.error('任务推送失败:' + e.message)
})
}
const showTask = (row) => {
item.value = row;
showTaskDialog.value = true;
};
item.value = row
showTaskDialog.value = true
}
const copyParams = (row) => {
params.value = row.params;
showTaskDialog.value = false;
};
params.value = row.params
showTaskDialog.value = false
}
const removeImage = (item) => {
ElMessageBox.confirm("此操作将会删除任务和图片,继续操作码?", "删除提示", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
ElMessageBox.confirm('此操作将会删除任务和图片,继续操作码?', '删除提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
httpGet("/api/sd/remove", { id: item.id })
httpGet('/api/sd/remove', { id: item.id })
.then(() => {
ElMessage.success("任务删除成功");
page.value = 0;
isOver.value = false;
fetchFinishJobs();
ElMessage.success('任务删除成功')
page.value = 0
isOver.value = false
fetchFinishJobs()
})
.catch((e) => {
ElMessage.error("任务删除失败:" + e.message);
});
ElMessage.error('任务删除失败:' + e.message)
})
})
.catch(() => {});
};
.catch(() => {})
}
// 发布图片到作品墙
const publishImage = (item, action) => {
let text = "图片发布";
let text = '图片发布'
if (action === false) {
text = "取消发布";
text = '取消发布'
}
httpGet("/api/sd/publish", { id: item.id, action: action })
httpGet('/api/sd/publish', { id: item.id, action: action })
.then(() => {
ElMessage.success(text + "成功");
item.publish = action;
page.value = 0;
isOver.value = false;
item.publish = action;
ElMessage.success(text + '成功')
item.publish = action
page.value = 0
isOver.value = false
item.publish = action
})
.catch((e) => {
ElMessage.error(text + "失败:" + e.message);
});
};
ElMessage.error(text + '失败:' + e.message)
})
}
const isGenerating = ref(false);
const isGenerating = ref(false)
const generatePrompt = () => {
if (params.value.prompt === "") {
return showMessageError("请输入原始提示词");
if (params.value.prompt === '') {
return showMessageError('请输入原始提示词')
}
isGenerating.value = true;
httpPost("/api/prompt/image", { prompt: params.value.prompt })
isGenerating.value = true
httpPost('/api/prompt/image', { prompt: params.value.prompt })
.then((res) => {
params.value.prompt = res.data;
isGenerating.value = false;
params.value.prompt = res.data
isGenerating.value = false
})
.catch((e) => {
showMessageError("生成提示词失败:" + e.message);
isGenerating.value = false;
});
};
showMessageError('生成提示词失败:' + e.message)
isGenerating.value = false
})
}
</script>
<style lang="stylus">
@import "@/assets/css/image-sd.styl"
@import "@/assets/css/custom-scroll.styl"
@import '@/assets/css/image-sd.styl';
@import '@/assets/css/custom-scroll.styl';
</style>

View File

@@ -11,149 +11,238 @@
</el-radio-group>
</div>
</div>
<div class="waterfall" :style="{ height: listBoxHeight + 'px' }" id="waterfall-box">
<v3-waterfall
<div
class="waterfall"
:style="{ height: listBoxHeight + 'px' }"
id="waterfall-box"
>
<Waterfall
v-if="imgType === 'mj'"
id="waterfall"
id="waterfall-mj"
:list="data['mj']"
srcKey="img_thumb"
:gap="12"
:bottomGap="-5"
:colWidth="colWidth"
:distanceToScroll="100"
:isLoading="loading"
:isOver="isOver"
@scrollReachBottom="getNext"
:row-key="waterfallOptions.rowKey"
:gutter="waterfallOptions.gutter"
:has-around-gutter="waterfallOptions.hasAroundGutter"
:width="waterfallOptions.width"
:breakpoints="waterfallOptions.breakpoints"
:img-selector="waterfallOptions.imgSelector"
:background-color="waterfallOptions.backgroundColor"
:animation-effect="waterfallOptions.animationEffect"
:animation-duration="waterfallOptions.animationDuration"
:animation-delay="waterfallOptions.animationDelay"
:animation-cancel="waterfallOptions.animationCancel"
:lazyload="waterfallOptions.lazyload"
:load-props="waterfallOptions.loadProps"
:cross-origin="waterfallOptions.crossOrigin"
:align="waterfallOptions.align"
:is-loading="loading"
:is-over="isOver"
@afterRender="loading = false"
>
<template #default="slotProp">
<div class="list-item">
<div class="image">
<el-image
:src="slotProp.item['img_thumb']"
:zoom-rate="1.2"
:preview-src-list="[slotProp.item['img_url']]"
:preview-teleported="true"
:initial-index="10"
loading="lazy"
>
<template #placeholder>
<div class="image-slot">正在加载图片</div>
</template>
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
<template #default="{ item, url }">
<div
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
>
<div class="overflow-hidden rounded-lg">
<LazyImg
:url="url"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
@click="previewImg(item)"
/>
</div>
<div class="opt">
<el-tooltip class="box-item" content="复制提示词" placement="top">
<el-icon class="copy-prompt-wall" :data-clipboard-text="slotProp.item.prompt">
<DocumentCopy />
</el-icon>
</el-tooltip>
<div class="px-4 pt-2 pb-4 border-t border-t-gray-800">
<div
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
>
<div class="opt">
<el-tooltip
class="box-item"
content="复制提示词"
placement="top"
>
<el-button
type="info"
circle
class="copy-prompt-wall"
:data-clipboard-text="item.prompt"
>
<i class="iconfont icon-file"></i>
</el-button>
</el-tooltip>
<el-tooltip class="box-item" content="画同款" placement="top">
<i class="iconfont icon-palette-pen" @click="drawSameMj(slotProp.item)"></i>
</el-tooltip>
<el-tooltip
class="box-item"
content="画同款"
placement="top"
>
<el-button
type="primary"
circle
@click="drawSameMj(item)"
>
<i class="iconfont icon-palette"></i>
</el-button>
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
</v3-waterfall>
</Waterfall>
<v3-waterfall
v-else-if="imgType === 'dall'"
id="waterfall"
:list="data['dall']"
srcKey="img_thumb"
:gap="12"
:bottomGap="-5"
:colWidth="colWidth"
:distanceToScroll="100"
:isLoading="loading"
:isOver="isOver"
@scrollReachBottom="getNext"
>
<template #default="slotProp">
<div class="list-item">
<div class="image">
<el-image
:src="slotProp.item['img_thumb']"
:zoom-rate="1.2"
:preview-src-list="[slotProp.item['img_url']]"
:preview-teleported="true"
:initial-index="10"
loading="lazy"
>
<template #placeholder>
<div class="image-slot">正在加载图片</div>
</template>
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
</div>
<div class="opt">
<el-tooltip class="box-item" content="复制提示词" placement="top">
<el-icon class="copy-prompt-wall" :data-clipboard-text="slotProp.item.prompt">
<DocumentCopy />
</el-icon>
</el-tooltip>
</div>
</div>
</template>
</v3-waterfall>
<v3-waterfall
v-else
id="waterfall"
<Waterfall
v-if="imgType === 'sd'"
id="waterfall-sd"
:list="data['sd']"
srcKey="img_thumb"
:gap="12"
:bottomGap="-5"
:colWidth="colWidth"
:distanceToScroll="100"
:isLoading="loading"
:isOver="isOver"
@scrollReachBottom="getNext"
:row-key="waterfallOptions.rowKey"
:gutter="waterfallOptions.gutter"
:has-around-gutter="waterfallOptions.hasAroundGutter"
:width="waterfallOptions.width"
:breakpoints="waterfallOptions.breakpoints"
:img-selector="waterfallOptions.imgSelector"
:background-color="waterfallOptions.backgroundColor"
:animation-effect="waterfallOptions.animationEffect"
:animation-duration="waterfallOptions.animationDuration"
:animation-delay="waterfallOptions.animationDelay"
:animation-cancel="waterfallOptions.animationCancel"
:lazyload="waterfallOptions.lazyload"
:load-props="waterfallOptions.loadProps"
:cross-origin="waterfallOptions.crossOrigin"
:align="waterfallOptions.align"
:is-loading="loading"
:is-over="isOver"
@afterRender="loading = false"
>
<template #default="slotProp">
<div class="list-item">
<div class="image">
<el-image :src="slotProp.item['img_thumb']" loading="lazy" @click="showTask(slotProp.item)">
<template #placeholder>
<div class="image-slot">正在加载图片</div>
</template>
<template #default="{ item, url }">
<div
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
>
<div class="overflow-hidden rounded-lg">
<LazyImg
:url="url"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
@click="showTask(item)"
/>
</div>
<div class="px-4 pt-2 pb-4 border-t border-t-gray-800">
<div
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
>
<div class="opt">
<el-tooltip
class="box-item"
content="复制提示词"
placement="top"
>
<el-button
type="info"
circle
class="copy-prompt-wall"
:data-clipboard-text="item.prompt"
>
<i class="iconfont icon-file"></i>
</el-button>
</el-tooltip>
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
<el-tooltip
class="box-item"
content="画同款"
placement="top"
>
<el-button
type="primary"
circle
@click="drawSameSd(item)"
>
<i class="iconfont icon-palette"></i>
</el-button>
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
</v3-waterfall>
</Waterfall>
<div class="footer" v-if="isOver">
<!-- <el-empty
:image-size="100"
:image="nodata"
description="没有更多数据了"
/> -->
<span>没有更多数据了</span>
<i class="iconfont icon-face"></i>
<Waterfall
v-if="imgType === 'dall'"
id="waterfall-dall"
:list="data['dall']"
:row-key="waterfallOptions.rowKey"
:gutter="waterfallOptions.gutter"
:has-around-gutter="waterfallOptions.hasAroundGutter"
:width="waterfallOptions.width"
:breakpoints="waterfallOptions.breakpoints"
:img-selector="waterfallOptions.imgSelector"
:background-color="waterfallOptions.backgroundColor"
:animation-effect="waterfallOptions.animationEffect"
:animation-duration="waterfallOptions.animationDuration"
:animation-delay="waterfallOptions.animationDelay"
:animation-cancel="waterfallOptions.animationCancel"
:lazyload="waterfallOptions.lazyload"
:load-props="waterfallOptions.loadProps"
:cross-origin="waterfallOptions.crossOrigin"
:align="waterfallOptions.align"
:is-loading="loading"
:is-over="isOver"
@afterRender="loading = false"
>
<template #default="{ item, url }">
<div
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
>
<div class="overflow-hidden rounded-lg">
<LazyImg
:url="url"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
@click="previewImg(item)"
/>
</div>
<div class="px-4 pt-2 pb-4 border-t border-t-gray-800">
<div
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
>
<div class="opt">
<el-tooltip
class="box-item"
content="复制提示词"
placement="top"
>
<el-button
type="info"
circle
class="copy-prompt-wall"
:data-clipboard-text="item.prompt"
>
<i class="iconfont icon-file"></i>
</el-button>
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
</Waterfall>
<div class="flex flex-col items-center justify-center py-10">
<img
:src="waterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]"
v-if="loading"
/>
<div v-else>
<button
class="px-5 py-2 rounded-full bg-purple-700 text-md text-white cursor-pointer hover:bg-purple-800 transition-all duration-300"
@click="getNext"
v-if="!isOver"
>
加载更多
</button>
<div class="no-more-data" v-else>
<span class="text-gray-500 mr-2">没有更多数据了</span>
<i class="iconfont icon-face"></i>
</div>
</div>
</div>
<back-top :right="30" :bottom="30" />
@@ -161,7 +250,23 @@
<!-- end of waterfall -->
</div>
<!-- 任务详情弹框 -->
<sd-task-view v-model="showTaskDialog" :data="item" @drawSame="drawSameSd" @close="showTaskDialog = false" />
<sd-task-view
v-model="showTaskDialog"
:data="item"
@drawSame="drawSameSd"
@close="showTaskDialog = false"
/>
<!-- 图片预览 -->
<el-image-viewer
@close="
() => {
previewURL = '';
}
"
v-if="previewURL !== ''"
:url-list="[previewURL]"
/>
</div>
</template>
@@ -175,6 +280,13 @@ import Clipboard from "clipboard";
import { useRouter } from "vue-router";
import BackTop from "@/components/BackTop.vue";
import SdTaskView from "@/components/SdTaskView.vue";
import { LazyImg, Waterfall } from "vue-waterfall-plugin-next";
import "vue-waterfall-plugin-next/dist/style.css";
import { useSharedStore } from "@/store/sharedata";
const store = useSharedStore();
const waterfallOptions = store.waterfallOptions;
const data = ref({
mj: [],
sd: [],
@@ -184,19 +296,12 @@ const loading = ref(true);
const isOver = ref(false);
const imgType = ref("mj"); // 图片类别
const listBoxHeight = window.innerHeight - 124;
const colWidth = ref(220);
const showTaskDialog = ref(false);
const item = ref({});
const previewURL = ref("");
// 计算瀑布流列宽度
const calcColWidth = () => {
const listBoxWidth = window.innerWidth - 60 - 80;
const rows = Math.floor(listBoxWidth / colWidth.value);
colWidth.value = Math.floor((listBoxWidth - (rows - 1) * 12) / rows);
};
calcColWidth();
window.onresize = () => {
calcColWidth();
const previewImg = (item) => {
previewURL.value = item.img_url;
};
const page = ref(0);
@@ -223,16 +328,17 @@ const getNext = () => {
}
httpGet(`${url}?page=${page.value}&page_size=${pageSize.value}`)
.then((res) => {
loading.value = false;
if (!res.data.items || res.data.items.length === 0) {
isOver.value = true;
loading.value = false;
return;
}
// 生成缩略图
const imageList = res.data.items;
for (let i = 0; i < imageList.length; i++) {
imageList[i]["img_thumb"] = imageList[i]["img_url"] + "?imageView2/4/w/300/h/0/q/75";
imageList[i]["img_thumb"] =
imageList[i]["img_url"] + "?imageView2/4/w/300/h/0/q/75";
}
if (data.value[imgType.value].length === 0) {
data.value[imgType.value] = imageList;
@@ -246,6 +352,7 @@ const getNext = () => {
})
.catch((e) => {
ElMessage.error("获取图片失败:" + e.message);
loading.value = false;
});
};
@@ -300,6 +407,6 @@ const drawSameMj = (row) => {
</script>
<style lang="stylus">
@import "@/assets/css/images-wall.styl"
@import "@/assets/css/custom-scroll.styl"
@import '@/assets/css/images-wall.styl';
@import '@/assets/css/custom-scroll.styl';
</style>

View File

@@ -5,7 +5,9 @@
<AccountTop>
<template #default>
<div class="wechatLog flex-center" v-if="wechatLoginURL !== ''">
<a :href="wechatLoginURL" @click="setRoute(router.currentRoute.value.path)"> <i class="iconfont icon-wechat"></i>使用微信登录 </a>
<a :href="wechatLoginURL" @click="setRoute(router.currentRoute.value.path)">
<i class="iconfont icon-wechat"></i>使用微信登录
</a>
</div>
</template>
</AccountTop>
@@ -14,18 +16,34 @@
<el-form ref="ruleFormRef" :model="ruleForm" :rules="rules">
<el-form-item label="" prop="username">
<div class="form-title">账号</div>
<el-input v-model="ruleForm.username" size="large" placeholder="请输入账号" @keyup="handleKeyup" />
<el-input
v-model="ruleForm.username"
size="large"
placeholder="请输入账号"
@keyup="handleKeyup"
/>
</el-form-item>
<el-form-item label="" prop="password">
<div class="flex-between w100">
<div class="form-title">密码</div>
<div class="form-forget text-color-primary" @click="router.push('/resetpassword')">忘记密码</div>
<div class="form-forget text-color-primary" @click="router.push('/resetpassword')">
忘记密码
</div>
</div>
<el-input size="large" v-model="ruleForm.password" placeholder="请输入密码" show-password autocomplete="off" @keyup="handleKeyup" />
<el-input
size="large"
v-model="ruleForm.password"
placeholder="请输入密码"
show-password
autocomplete="off"
@keyup="handleKeyup"
/>
</el-form-item>
<el-form-item>
<el-button class="login-btn" size="large" type="primary" @click="login">登录</el-button>
<el-button class="login-btn" size="large" type="primary" @click="login"
>登录</el-button
>
</el-form-item>
</el-form>
</div>
@@ -38,96 +56,105 @@
</template>
<script setup>
import { onMounted, ref, reactive } from "vue";
import { httpGet, httpPost } from "@/utils/http";
import { useRouter } from "vue-router";
import AccountBg from "@/components/AccountBg.vue";
import { isMobile } from "@/utils/libs";
import { checkSession, getLicenseInfo, getSystemInfo } from "@/store/cache";
import { setUserToken } from "@/store/session";
import { showMessageError } from "@/utils/dialog";
import { setRoute } from "@/store/system";
import { useSharedStore } from "@/store/sharedata";
import AccountBg from '@/components/AccountBg.vue'
import { checkSession, getLicenseInfo, getSystemInfo } from '@/store/cache'
import { setUserToken } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import { setRoute } from '@/store/system'
import { showMessageError } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AccountTop from "@/components/AccountTop.vue";
import Captcha from "@/components/Captcha.vue";
import AccountTop from '@/components/AccountTop.vue'
import Captcha from '@/components/Captcha.vue'
const router = useRouter();
const title = ref("");
const router = useRouter()
const title = ref('')
const logo = ref("");
const licenseConfig = ref({});
const wechatLoginURL = ref("");
const enableVerify = ref(false);
const captchaRef = ref(null);
const ruleFormRef = ref(null);
const logo = ref('')
const licenseConfig = ref({})
const wechatLoginURL = ref('')
const enableVerify = ref(false)
const captchaRef = ref(null)
const ruleFormRef = ref(null)
const ruleForm = reactive({
username: process.env.VUE_APP_USER,
password: process.env.VUE_APP_PASS,
});
})
const rules = {
username: [{ required: true, trigger: "blur", message: "请输入账号" }],
password: [{ required: true, trigger: "blur", message: "请输入密码" }],
};
username: [{ required: true, trigger: 'blur', message: '请输入账号' }],
password: [{ required: true, trigger: 'blur', message: '请输入密码' }],
}
onMounted(() => {
// 检查URL中是否存在token参数
const urlParams = new URLSearchParams(window.location.search)
const token = urlParams.get('token')
if (token) {
setUserToken(token)
store.setIsLogin(true)
router.push('/chat')
return
}
// 获取系统配置
getSystemInfo()
.then((res) => {
logo.value = res.data.logo;
title.value = res.data.title;
enableVerify.value = res.data["enabled_verify"];
logo.value = res.data.logo
title.value = res.data.title
enableVerify.value = res.data['enabled_verify']
})
.catch((e) => {
showMessageError("获取系统配置失败:" + e.message);
title.value = "Geek-AI";
});
showMessageError('获取系统配置失败:' + e.message)
title.value = 'Geek-AI'
})
getLicenseInfo()
.then((res) => {
licenseConfig.value = res.data;
licenseConfig.value = res.data
})
.catch((e) => {
showMessageError("获取 License 配置:" + e.message);
});
showMessageError('获取 License 配置:' + e.message)
})
checkSession()
.then(() => {
router.back();
router.back()
})
.catch(() => {});
.catch(() => {})
const returnURL = `${location.protocol}//${location.host}/login/callback?action=login`;
httpGet("/api/user/clogin?return_url=" + returnURL)
const returnURL = `${location.protocol}//${location.host}/login/callback?action=login`
httpGet('/api/user/clogin?return_url=' + returnURL)
.then((res) => {
wechatLoginURL.value = res.data.url;
wechatLoginURL.value = res.data.url
})
.catch((e) => {
console.error(e);
});
});
console.error(e)
})
})
const handleKeyup = (e) => {
if (e.key === "Enter") {
login();
if (e.key === 'Enter') {
login()
}
};
}
const login = async function () {
await ruleFormRef.value.validate(async (valid) => {
if (valid) {
if (enableVerify.value) {
captchaRef.value.loadCaptcha();
captchaRef.value.loadCaptcha()
} else {
doLogin({});
doLogin({})
}
}
});
};
})
}
const store = useSharedStore();
const store = useSharedStore()
const doLogin = (verifyData) => {
httpPost("/api/user/login", {
httpPost('/api/user/login', {
username: ruleForm.username,
password: ruleForm.password,
key: verifyData.key,
@@ -135,14 +162,14 @@ const doLogin = (verifyData) => {
x: verifyData.x,
})
.then((res) => {
setUserToken(res.data.token);
store.setIsLogin(true);
router.back();
setUserToken(res.data.token)
store.setIsLogin(true)
router.back()
})
.catch((e) => {
showMessageError("登录失败," + e.message);
});
};
showMessageError('登录失败,' + e.message)
})
}
</script>
<style lang="stylus" scoped>

View File

@@ -19,11 +19,7 @@
<div class="param-line">请选择生成思维导图的AI模型</div>
<div class="param-line">
<el-select
v-model="modelID"
placeholder="请选择模型"
style="width: 100%"
>
<el-select v-model="modelID" placeholder="请选择模型" style="width: 100%">
<el-option
v-for="item in models"
:key="item.id"
@@ -43,9 +39,7 @@
<div class="text-info">
<el-text type="primary"
>当前可用算力<el-text type="warning">{{
loginUser.power
}}</el-text></el-text
>当前可用算力<el-text type="warning">{{ loginUser.power }}</el-text></el-text
>
</div>
@@ -115,179 +109,186 @@
</template>
<script setup>
import { nextTick, ref } from "vue";
import { Markmap } from "markmap-view";
import { Transformer } from "markmap-lib";
import { checkSession, getSystemInfo } from "@/store/cache";
import { httpGet, httpPost } from "@/utils/http";
import { ElMessage } from "element-plus";
import { Download } from "@element-plus/icons-vue";
import { Toolbar } from "markmap-toolbar";
import { useSharedStore } from "@/store/sharedata";
import { nextTick, onMounted, ref } from 'vue'
import { Markmap } from 'markmap-view'
import { Transformer } from 'markmap-lib'
import { checkSession, getSystemInfo } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { Toolbar } from 'markmap-toolbar'
import { useSharedStore } from '@/store/sharedata'
const leftBoxHeight = ref(window.innerHeight - 105);
const leftBoxHeight = ref(window.innerHeight - 105)
//const rightBoxHeight = ref(window.innerHeight - 115);
const rightBoxHeight = ref(window.innerHeight);
const rightBoxHeight = ref(window.innerHeight)
const prompt = ref("");
const text = ref("");
const content = ref(text.value);
const html = ref("");
const prompt = ref('')
const text = ref('')
const content = ref(text.value)
const html = ref('')
const isLogin = ref(false);
const loginUser = ref({ power: 0 });
const transformer = new Transformer();
const store = useSharedStore();
const loading = ref(false);
const isLogin = ref(false)
const loginUser = ref({ power: 0 })
const transformer = new Transformer()
const store = useSharedStore()
const loading = ref(false)
const svgRef = ref(null);
const markMap = ref(null);
const models = ref([]);
const modelID = ref(0);
const svgRef = ref(null)
const markMap = ref(null)
const models = ref([])
const modelID = ref(0)
const cacheKey = ref('MarkMapCache')
getSystemInfo()
.then((res) => {
text.value = res.data["mark_map_text"];
content.value = text.value;
initData();
nextTick(() => {
try {
markMap.value = Markmap.create(svgRef.value);
const { el } = Toolbar.create(markMap.value);
document.getElementById("toolbar").append(el);
update();
} catch (e) {
console.error(e);
}
});
onMounted(async () => {
const cache = localStorage.getItem(cacheKey.value)
if (cache) {
text.value = cache
} else {
const res = await getSystemInfo().catch((e) => {
ElMessage.error('获取系统配置失败:' + e.message)
})
text.value = res.data['mark_map_text']
content.value = text.value
}
initData()
nextTick(() => {
try {
markMap.value = Markmap.create(svgRef.value)
const { el } = Toolbar.create(markMap.value)
document.getElementById('toolbar').append(el)
update()
} catch (e) {
console.error(e)
}
})
.catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message);
});
})
const initData = () => {
httpGet("/api/model/list")
httpGet('/api/model/list')
.then((res) => {
for (let v of res.data) {
models.value.push(v);
models.value.push(v)
}
modelID.value = models.value[0].id;
modelID.value = models.value[0].id
})
.catch((e) => {
ElMessage.error("获取模型失败:" + e.message);
});
ElMessage.error('获取模型失败:' + e.message)
})
checkSession()
.then((user) => {
loginUser.value = user;
isLogin.value = true;
loginUser.value = user
isLogin.value = true
})
.catch(() => {});
};
.catch(() => {})
}
const update = () => {
try {
const { root } = transformer.transform(processContent(text.value));
markMap.value.setData(root);
markMap.value.fit();
const { root } = transformer.transform(processContent(text.value))
markMap.value.setData(root)
markMap.value.fit()
} catch (e) {
console.error(e);
console.error(e)
}
};
}
const processContent = (text) => {
if (!text) {
return text;
return text
}
const arr = [];
const lines = text.split("\n");
const arr = []
const lines = text.split('\n')
for (let line of lines) {
if (line.indexOf("```") !== -1) {
continue;
if (line.indexOf('```') !== -1) {
continue
}
line = line.replace(/([*_~`>])|(\d+\.)\s/g, "");
arr.push(line);
line = line.replace(/([*_~`>])|(\d+\.)\s/g, '')
arr.push(line)
}
return arr.join("\n");
};
return arr.join('\n')
}
window.onresize = () => {
leftBoxHeight.value = window.innerHeight - 145;
rightBoxHeight.value = window.innerHeight - 85;
};
leftBoxHeight.value = window.innerHeight - 145
rightBoxHeight.value = window.innerHeight - 85
}
const generate = () => {
text.value = content.value;
update();
};
text.value = content.value
update()
}
// 使用 AI 智能生成
const generateAI = () => {
html.value = "";
text.value = "";
if (prompt.value === "") {
return ElMessage.error("请输入你的需求");
html.value = ''
text.value = ''
if (prompt.value === '') {
return ElMessage.error('请输入你的需求')
}
if (!isLogin.value) {
store.setShowLoginDialog(true);
return;
store.setShowLoginDialog(true)
return
}
loading.value = true;
httpPost("/api/markMap/gen", {
loading.value = true
httpPost('/api/markMap/gen', {
prompt: prompt.value,
model_id: modelID.value
model_id: modelID.value,
})
.then((res) => {
text.value = res.data;
content.value = processContent(text.value);
const model = getModelById(modelID.value);
loginUser.value.power -= model.power;
nextTick(() => update());
loading.value = false;
text.value = res.data
content.value = processContent(text.value)
const model = getModelById(modelID.value)
loginUser.value.power -= model.power
nextTick(() => update())
loading.value = false
// 缓存结果
localStorage.setItem(cacheKey.value, text.value)
})
.catch((e) => {
ElMessage.error("生成思维导图失败:" + e.message);
loading.value = false;
});
};
ElMessage.error('生成思维导图失败:' + e.message)
loading.value = false
})
}
const getModelById = (modelId) => {
for (let m of models.value) {
if (m.id === modelId) {
return m;
return m
}
}
};
}
// download SVG to png file
const downloadImage = () => {
const svgElement = document.getElementById("markmap");
const svgElement = document.getElementById('markmap')
// 将 SVG 渲染到图片对象
const serializer = new XMLSerializer();
const serializer = new XMLSerializer()
const source =
'<?xml version="1.0" standalone="no"?>\r\n' +
serializer.serializeToString(svgRef.value);
const image = new Image();
image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(source);
'<?xml version="1.0" standalone="no"?>\r\n' + serializer.serializeToString(svgRef.value)
const image = new Image()
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source)
// 将图片对象渲染
const canvas = document.createElement("canvas");
canvas.width = svgElement.offsetWidth;
canvas.height = svgElement.offsetHeight;
let context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "white";
context.fillRect(0, 0, canvas.width, canvas.height);
const canvas = document.createElement('canvas')
canvas.width = svgElement.offsetWidth
canvas.height = svgElement.offsetHeight
let context = canvas.getContext('2d')
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillStyle = 'white'
context.fillRect(0, 0, canvas.width, canvas.height)
image.onload = function () {
context.drawImage(image, 0, 0);
const a = document.createElement("a");
a.download = "geek-ai-xmind.png";
a.href = canvas.toDataURL(`image/png`);
a.click();
};
};
context.drawImage(image, 0, 0)
const a = document.createElement('a')
a.download = 'geek-ai-xmind.png'
a.href = canvas.toDataURL(`image/png`)
a.click()
}
}
</script>
<style lang="stylus">

View File

@@ -1,471 +0,0 @@
<template>
<div data-component="ConsolePage">
<div class="content-top">
<div class="content-title">
<img src="/openai-logomark.svg" alt="OpenAI Logo" />
<span>realtime console</span>
</div>
</div>
<div class="content-main">
<div class="content-logs">
<div class="content-block events">
<div class="visualization">
<div class="visualization-entry client">
<canvas ref="clientCanvasRef" />
</div>
<div class="visualization-entry server">
<canvas ref="serverCanvasRef" />
</div>
</div>
<div class="content-block-title">events</div>
<div class="content-block-body" ref="eventsScrollRef">
<template v-if="!realtimeEvents.length">
awaiting connection...
</template>
<template v-else>
<div v-for="(realtimeEvent, i) in realtimeEvents" :key="realtimeEvent.event.event_id" class="event">
<div class="event-timestamp">
{{ formatTime(realtimeEvent.time) }}
</div>
<div class="event-details">
<div
class="event-summary"
@click="toggleEventDetails(realtimeEvent.event.event_id)"
>
<div
:class="[
'event-source',
realtimeEvent.event.type === 'error'
? 'error'
: realtimeEvent.source,
]"
>
<component :is="realtimeEvent.source === 'client' ? ArrowUp : ArrowDown" />
<span>
{{ realtimeEvent.event.type === 'error'
? 'error!'
: realtimeEvent.source }}
</span>
</div>
<div class="event-type">
{{ realtimeEvent.event.type }}
{{ realtimeEvent.count ? `(${realtimeEvent.count})` : '' }}
</div>
</div>
<div
v-if="expandedEvents[realtimeEvent.event.event_id]"
class="event-payload"
>
{{ JSON.stringify(realtimeEvent.event, null, 2) }}
</div>
</div>
</div>
</template>
</div>
</div>
<div class="content-block conversation">
<div class="content-block-title">conversation</div>
<div class="content-block-body" data-conversation-content>
<template v-if="!items.length">
awaiting connection...
</template>
<template v-else>
<div
v-for="(conversationItem, i) in items"
:key="conversationItem.id"
class="conversation-item"
>
<div :class="['speaker', conversationItem.role || '']">
<div>
{{
(conversationItem.role || conversationItem.type).replaceAll(
'_',
' '
)
}}
</div>
<div class="close" @click="deleteConversationItem(conversationItem.id)">
<X />
</div>
</div>
<div class="speaker-content">
<!-- tool response -->
<div v-if="conversationItem.type === 'function_call_output'">
{{ conversationItem.formatted.output }}
</div>
<!-- tool call -->
<div v-if="conversationItem.formatted.tool">
{{ conversationItem.formatted.tool.name }}(
{{ conversationItem.formatted.tool.arguments }})
</div>
<div
v-if="
!conversationItem.formatted.tool &&
conversationItem.role === 'user'
"
>
{{
conversationItem.formatted.transcript ||
(conversationItem.formatted.audio?.length
? '(awaiting transcript)'
: conversationItem.formatted.text || '(item sent)')
}}
</div>
<div
v-if="
!conversationItem.formatted.tool &&
conversationItem.role === 'assistant'
"
>
{{
conversationItem.formatted.transcript ||
conversationItem.formatted.text ||
'(truncated)'
}}
</div>
<audio
v-if="conversationItem.formatted.file"
:src="conversationItem.formatted.file.url"
controls
/>
</div>
</div>
</template>
</div>
</div>
<div class="content-actions" style="position:absolute; top: 0; left: 0">
<el-button
:type="isConnected ? '' : 'primary'"
@click="connectConversation"
>
{{isConnected ? '断开连接' : '连接对话'}}
</el-button>
<el-button @mousedown="startRecording" @mouseup="stopRecording">开始讲话</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, watch } from 'vue';
import { RealtimeClient } from '@openai/realtime-api-beta';
import { WavRecorder, WavStreamPlayer } from '@/lib/wavtools/index.js';
import { instructions } from '@/utils/conversation_config.js';
import { WavRenderer } from '@/utils/wav_renderer';
// Constants
const LOCAL_RELAY_SERVER_URL = process.env.REACT_APP_LOCAL_RELAY_SERVER_URL || '';
// Reactive state
const apiKey = ref(
LOCAL_RELAY_SERVER_URL
? ''
: localStorage.getItem('tmp::voice_api_key') || prompt('OpenAI API Key') || ''
);
const wavRecorder = ref(new WavRecorder({ sampleRate: 24000 }));
const wavStreamPlayer = ref(new WavStreamPlayer({ sampleRate: 24000 }));
const client = ref(
new RealtimeClient({
url: "ws://localhost:5678/api/realtime",
apiKey: "sk-Gc5cEzDzGQLIqxWA9d62089350F3454bB359C4A3Fa21B3E4",
dangerouslyAllowAPIKeyInBrowser: true,
})
);
const clientCanvasRef = ref(null);
const serverCanvasRef = ref(null);
const eventsScrollRef = ref(null);
const startTime = ref(new Date().toISOString());
const items = ref([]);
const realtimeEvents = ref([]);
const expandedEvents = reactive({});
const isConnected = ref(false);
const canPushToTalk = ref(true);
const isRecording = ref(false);
const memoryKv = ref({});
const coords = ref({ lat: 37.775593, lng: -122.418137 });
const marker = ref(null);
// Methods
const formatTime = (timestamp) => {
const t0 = new Date(startTime.value).valueOf();
const t1 = new Date(timestamp).valueOf();
const delta = t1 - t0;
const hs = Math.floor(delta / 10) % 100;
const s = Math.floor(delta / 1000) % 60;
const m = Math.floor(delta / 60_000) % 60;
const pad = (n) => {
let s = n + '';
while (s.length < 2) {
s = '0' + s;
}
return s;
};
return `${pad(m)}:${pad(s)}.${pad(hs)}`;
};
const connectConversation = async () => {
alert(123)
startTime.value = new Date().toISOString();
isConnected.value = true;
realtimeEvents.value = [];
items.value = client.value.conversation.getItems();
await wavRecorder.value.begin();
await wavStreamPlayer.value.connect();
await client.value.connect();
client.value.sendUserMessageContent([
{
type: 'input_text',
text: '你好,我是老阳!',
},
]);
if (client.value.getTurnDetectionType() === 'server_vad') {
await wavRecorder.value.record((data) => client.value.appendInputAudio(data.mono));
}
};
const disconnectConversation = async () => {
isConnected.value = false;
realtimeEvents.value = [];
items.value = [];
memoryKv.value = {};
coords.value = { lat: 37.775593, lng: -122.418137 };
marker.value = null;
client.value.disconnect();
await wavRecorder.value.end();
await wavStreamPlayer.value.interrupt();
};
const deleteConversationItem = async (id) => {
client.value.deleteItem(id);
};
const startRecording = async () => {
isRecording.value = true;
const trackSampleOffset = await wavStreamPlayer.value.interrupt();
if (trackSampleOffset?.trackId) {
const { trackId, offset } = trackSampleOffset;
await client.value.cancelResponse(trackId, offset);
}
await wavRecorder.value.record((data) => client.value.appendInputAudio(data.mono));
};
const stopRecording = async () => {
isRecording.value = false;
await wavRecorder.value.pause();
client.value.createResponse();
};
const changeTurnEndType = async (value) => {
if (value === 'none' && wavRecorder.value.getStatus() === 'recording') {
await wavRecorder.value.pause();
}
client.value.updateSession({
turn_detection: value === 'none' ? null : { type: 'server_vad' },
});
if (value === 'server_vad' && client.value.isConnected()) {
await wavRecorder.value.record((data) => client.value.appendInputAudio(data.mono));
}
canPushToTalk.value = value === 'none';
};
const toggleEventDetails = (eventId) => {
if (expandedEvents[eventId]) {
delete expandedEvents[eventId];
} else {
expandedEvents[eventId] = true;
}
};
// Lifecycle hooks and watchers
onMounted(() => {
if (apiKey.value !== '') {
localStorage.setItem('tmp::voice_api_key', apiKey.value);
}
// Set up render loops for the visualization canvas
let isLoaded = true;
const render = () => {
if (isLoaded) {
if (clientCanvasRef.value) {
const canvas = clientCanvasRef.value;
if (!canvas.width || !canvas.height) {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
}
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const result = wavRecorder.value.recording
? wavRecorder.value.getFrequencies('voice')
: { values: new Float32Array([0]) };
WavRenderer.drawBars(canvas, ctx, result.values, '#0099ff', 10, 0, 8);
}
}
if (serverCanvasRef.value) {
const canvas = serverCanvasRef.value;
if (!canvas.width || !canvas.height) {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
}
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const result = wavStreamPlayer.value.analyser
? wavStreamPlayer.value.getFrequencies('voice')
: { values: new Float32Array([0]) };
WavRenderer.drawBars(canvas, ctx, result.values, '#009900', 10, 0, 8);
}
}
requestAnimationFrame(render);
}
};
render();
// Set up client event listeners
client.value.on('realtime.event', (realtimeEvent) => {
realtimeEvents.value = realtimeEvents.value.slice();
const lastEvent = realtimeEvents.value[realtimeEvents.value.length - 1];
if (lastEvent?.event.type === realtimeEvent.event.type) {
lastEvent.count = (lastEvent.count || 0) + 1;
realtimeEvents.value.splice(-1, 1, lastEvent);
} else {
realtimeEvents.value.push(realtimeEvent);
}
});
client.value.on('error', (event) => console.error(event));
client.value.on('conversation.interrupted', async () => {
const trackSampleOffset = await wavStreamPlayer.value.interrupt();
if (trackSampleOffset?.trackId) {
const { trackId, offset } = trackSampleOffset;
await client.value.cancelResponse(trackId, offset);
}
});
client.value.on('conversation.updated', async ({ item, delta }) => {
items.value = client.value.conversation.getItems();
if (delta?.audio) {
wavStreamPlayer.value.add16BitPCM(delta.audio, item.id);
}
if (item.status === 'completed' && item.formatted.audio?.length) {
const wavFile = await WavRecorder.decode(
item.formatted.audio,
24000,
24000
);
item.formatted.file = wavFile;
}
});
// Set up client instructions and tools
client.value.updateSession({ instructions: instructions });
client.value.updateSession({ input_audio_transcription: { model: 'whisper-1' } });
client.value.addTool(
{
name: 'set_memory',
description: 'Saves important data about the user into memory.',
parameters: {
type: 'object',
properties: {
key: {
type: 'string',
description:
'The key of the memory value. Always use lowercase and underscores, no other characters.',
},
value: {
type: 'string',
description: 'Value can be anything represented as a string',
},
},
required: ['key', 'value'],
},
},
async ({ key, value }) => {
memoryKv.value = { ...memoryKv.value, [key]: value };
return { ok: true };
}
);
client.value.addTool(
{
name: 'get_weather',
description:
'Retrieves the weather for a given lat, lng coordinate pair. Specify a label for the location.',
parameters: {
type: 'object',
properties: {
lat: {
type: 'number',
description: 'Latitude',
},
lng: {
type: 'number',
description: 'Longitude',
},
location: {
type: 'string',
description: 'Name of the location',
},
},
required: ['lat', 'lng', 'location'],
},
},
async ({ lat, lng, location }) => {
marker.value = { lat, lng, location };
coords.value = { lat, lng, location };
const result = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m`
);
const json = await result.json();
const temperature = {
value: json.current.temperature_2m,
units: json.current_units.temperature_2m,
};
const wind_speed = {
value: json.current.wind_speed_10m,
units: json.current_units.wind_speed_10m,
};
marker.value = { lat, lng, location, temperature, wind_speed };
return json;
}
);
items.value = client.value.conversation.getItems();
});
onUnmounted(() => {
client.value.reset();
});
// Watchers
watch(realtimeEvents, () => {
if (eventsScrollRef.value) {
const eventsEl = eventsScrollRef.value;
eventsEl.scrollTop = eventsEl.scrollHeight;
}
});
watch(items, () => {
const conversationEls = document.querySelectorAll('[data-conversation-content]');
conversationEls.forEach((el) => {
el.scrollTop = el.scrollHeight;
});
});
</script>
<style scoped>
/* You can add your component-specific styles here */
/* If you're using SCSS, you might want to import your existing SCSS file */
/* @import './ConsolePage.scss'; */
</style>

View File

@@ -142,6 +142,7 @@ const types = ref([
{ label: "Luma视频", value: "luma" },
{ label: "可灵视频", value: "keling" },
{ label: "Realtime API", value: "realtime" },
{ label: "语音合成", value: "tts" },
{ label: "其他", value: "other" },
]);
const isEdit = ref(false);

View File

@@ -126,6 +126,16 @@
</el-form-item>
</div>
<div v-if="item.type === 'tts'">
<el-form-item label="音色" prop="voice">
<el-select v-model="item.options.voice" placeholder="请选择音色">
<el-option v-for="v in voices" :value="v.value" :label="v.label" :key="v.value">
{{ v.label }}
</el-option>
</el-select>
</el-form-item>
</div>
<el-form-item label="绑定API-KEY" prop="apikey">
<el-select v-model="item.key_id" placeholder="请选择 API KEY" filterable clearable>
<el-option v-for="v in apiKeys" :value="v.id" :label="v.name" :key="v.id">
@@ -191,6 +201,15 @@ const formRef = ref(null);
const type = ref([
{ label: "聊天", value: "chat" },
{ label: "绘图", value: "img" },
{ label: "语音", value: "tts" },
]);
const voices = ref([
{ label: "Echo", value: "echo" },
{ label: "Fable", value: "fable" },
{ label: "Onyx", value: "onyx" },
{ label: "Nova", value: "nova" },
{ label: "Shimmer", value: "shimmer" },
]);
// 获取 API KEY
@@ -270,7 +289,7 @@ onUnmounted(() => {
const add = function () {
title.value = "新增模型";
showDialog.value = true;
item.value = { enabled: true, power: 1, open: true, max_tokens: 1024, max_context: 8192, temperature: 0.9 };
item.value = { enabled: true, power: 1, open: true, max_tokens: 1024, max_context: 8192, temperature: 0.9, options: {} };
};
const edit = function (row) {
@@ -282,9 +301,6 @@ const edit = function (row) {
const save = function () {
formRef.value.validate((valid) => {
item.value.temperature = parseFloat(item.value.temperature);
if (!item.value.sort_num) {
item.value.sort_num = items.value.length;
}
if (valid) {
showDialog.value = false;
item.value.key_id = parseInt(item.value.key_id);

View File

@@ -10,13 +10,24 @@
</div>
<el-row>
<el-table :data="users.items" border class="table" :row-key="(row) => row.id" @selection-change="handleSelectionChange" table-layout="auto">
<el-table
:data="users.items"
border
class="table"
:row-key="(row) => row.id"
@selection-change="handleSelectionChange"
table-layout="auto"
>
<el-table-column type="selection" width="38"></el-table-column>
<el-table-column prop="id" label="ID" />
<el-table-column label="账号">
<template #default="scope">
<span>{{ scope.row.username }}</span>
<el-image v-if="scope.row.vip" :src="vipImg" style="height: 20px; position: relative; top: 5px; left: 5px" />
<el-image
v-if="scope.row.vip"
:src="vipImg"
style="height: 20px; position: relative; top: 5px; left: 5px"
/>
</template>
</el-table-column>
<el-table-column prop="mobile" label="手机" />
@@ -31,24 +42,42 @@
</el-table-column>
<el-table-column label="过期时间">
<template #default="scope">
<span v-if="scope.row['expired_time']">{{ scope.row["expired_time"] }}</span>
<span v-if="scope.row['expired_time']">{{ scope.row['expired_time'] }}</span>
<el-tag v-else>长期有效</el-tag>
</template>
</el-table-column>
<el-table-column label="注册时间">
<template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span>
<span>{{ dateFormat(scope.row['created_at']) }}</span>
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="220">
<el-table-column fixed="right" label="操作">
<template #default="scope">
<el-button-group class="ml-4">
<el-button size="small" type="primary" @click="userEdit(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="removeUser(scope.row)">删除</el-button>
<el-button size="small" type="success" @click="resetPass(scope.row)">重置密码</el-button>
</el-button-group>
<el-dropdown>
<button
class="px-2 py-1 bg-purple-200 hover:bg-purple-300 rounded text-purple-800 text-sm"
>
<i class="iconfont icon-more-horizontal"></i>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="userEdit(scope.row)">
<i class="iconfont icon-edit mr-1"></i>编辑
</el-dropdown-item>
<el-dropdown-item @click="removeUser(scope.row)" class="text-red-500">
<i class="iconfont icon-remove mr-1"></i>删除
</el-dropdown-item>
<el-dropdown-item @click="resetPass(scope.row)">
<i class="iconfont icon-password mr-1"></i>重置密码
</el-dropdown-item>
<el-dropdown-item @click="genLoginLink(scope.row)">
<i class="iconfont icon-link mr-1"></i>生成登录链接
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
@@ -66,7 +95,12 @@
</div>
</el-row>
<el-dialog v-model="showUserEditDialog" :title="title" :close-on-click-modal="false" width="50%">
<el-dialog
v-model="showUserEditDialog"
:title="title"
:close-on-click-modal="false"
width="50%"
>
<el-form :model="user" label-width="100px" ref="userEditFormRef" :rules="rules">
<el-form-item label="账号" prop="username">
<el-input v-model="user.username" autocomplete="off" />
@@ -96,13 +130,23 @@
</el-form-item>
<el-form-item label="聊天角色" prop="chat_roles">
<el-select v-model="user.chat_roles" multiple :filterable="true" placeholder="选择聊天角色多选">
<el-select
v-model="user.chat_roles"
multiple
:filterable="true"
placeholder="选择聊天角色多选"
>
<el-option v-for="item in roles" :key="item.key" :label="item.name" :value="item.key" />
</el-select>
</el-form-item>
<el-form-item label="模型权限" prop="chat_models">
<el-select v-model="user.chat_models" multiple :filterable="true" placeholder="选择AI模型多选">
<el-select
v-model="user.chat_models"
multiple
:filterable="true"
placeholder="选择AI模型多选"
>
<el-option v-for="item in models" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
@@ -141,204 +185,241 @@
</span>
</template>
</el-dialog>
<el-dialog v-model="showGenLoginLinkDialog" title="自动登录链接" width="50%">
<el-input v-model="loginLink" readonly disabled />
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="copyLoginLink">复制链接</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { httpGet, httpPost } from "@/utils/http";
import { ElMessage, ElMessageBox } from "element-plus";
import { dateFormat, disabledDate } from "@/utils/libs";
import { Delete, Plus, Search } from "@element-plus/icons-vue";
import { onMounted, reactive, ref } from 'vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import { dateFormat, disabledDate } from '@/utils/libs'
import { Delete, Plus, Search } from '@element-plus/icons-vue'
import { showLoading, closeLoading, showMessageError, showMessageOK } from '@/utils/dialog'
// 变量定义
const users = ref({ page: 1, page_size: 15, items: [] });
const query = ref({ username: "", page: 1, page_size: 15 });
const users = ref({ page: 1, page_size: 15, items: [] })
const query = ref({ username: '', page: 1, page_size: 15 })
const title = ref("添加用户");
const vipImg = ref("/images/menu/member.png");
const add = ref(true);
const user = ref({ chat_roles: [], chat_models: [] });
const pass = ref({ username: "", password: "", id: 0 });
const roles = ref([]);
const models = ref([]);
const showUserEditDialog = ref(false);
const showResetPassDialog = ref(false);
const title = ref('添加用户')
const vipImg = ref('/images/menu/member.png')
const add = ref(true)
const user = ref({ chat_roles: [], chat_models: [] })
const pass = ref({ username: '', password: '', id: 0 })
const roles = ref([])
const models = ref([])
const showUserEditDialog = ref(false)
const showResetPassDialog = ref(false)
const rules = reactive({
username: [{ required: true, message: "请输入账号", trigger: "blur" }],
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [
{
required: true,
validator: (rule, value) => {
return !(value.length > 16 || value.length < 8);
return !(value.length > 16 || value.length < 8)
},
message: "密码必须为8-16",
trigger: "blur",
message: '密码必须为8-16',
trigger: 'blur',
},
],
calls: [
{ required: true, message: "请输入提问次数" },
{ type: "number", message: "请输入有效数字" },
{ required: true, message: '请输入提问次数' },
{ type: 'number', message: '请输入有效数字' },
],
chat_roles: [{ required: true, message: "请选择聊天角色", trigger: "change" }],
chat_models: [{ required: true, message: "请选择AI模型", trigger: "change" }],
});
const loading = ref(true);
chat_roles: [{ required: true, message: '请选择聊天角色', trigger: 'change' }],
chat_models: [{ required: true, message: '请选择AI模型', trigger: 'change' }],
})
const loading = ref(true)
const userEditFormRef = ref(null);
const userEditFormRef = ref(null)
onMounted(() => {
fetchUserList(users.value.page, users.value.page_size);
fetchUserList(users.value.page, users.value.page_size)
// 获取角色列表
httpGet("/api/admin/role/list")
httpGet('/api/admin/role/list')
.then((res) => {
roles.value = res.data;
roles.value = res.data
})
.catch(() => {
ElMessage.error("获取聊天角色失败");
});
ElMessage.error('获取聊天角色失败')
})
httpGet("/api/admin/model/list")
httpGet('/api/admin/model/list')
.then((res) => {
models.value = res.data;
models.value = res.data
})
.catch((e) => {
ElMessage.error("获取模型失败" + e.message);
});
});
ElMessage.error('获取模型失败' + e.message)
})
})
const fetchUserList = function (page, pageSize) {
query.value.page = page;
query.value.page_size = pageSize;
httpGet("/api/admin/user/list", query.value)
query.value.page = page
query.value.page_size = pageSize
httpGet('/api/admin/user/list', query.value)
.then((res) => {
if (res.data) {
// 初始化数据
const arr = res.data.items;
const arr = res.data.items
for (let i = 0; i < arr.length; i++) {
arr[i].expired_time = dateFormat(arr[i].expired_time);
arr[i].expired_time = dateFormat(arr[i].expired_time)
}
users.value.items = arr;
users.value.total = res.data.total;
users.value.page = res.data.page;
user.value.page_size = res.data.page_size;
users.value.items = arr
users.value.total = res.data.total
users.value.page = res.data.page
user.value.page_size = res.data.page_size
}
loading.value = false;
loading.value = false
})
.catch(() => {
ElMessage.error("加载用户列表失败");
});
};
ElMessage.error('加载用户列表失败')
})
}
const handleSearch = () => {
fetchUserList(users.value.page, users.value.page_size);
};
fetchUserList(users.value.page, users.value.page_size)
}
// 删除用户
const removeUser = function (user) {
ElMessageBox.confirm("此操作将会永久删除用户信息和聊天记录确认操作吗?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
ElMessageBox.confirm('此操作将会永久删除用户信息和聊天记录,确认操作吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
httpGet("/api/admin/user/remove", { id: user.id })
httpGet('/api/admin/user/remove', { id: user.id })
.then(() => {
ElMessage.success("操作成功");
fetchUserList(users.value.page, users.value.page_size);
ElMessage.success('操作成功')
fetchUserList(users.value.page, users.value.page_size)
})
.catch((e) => {
ElMessage.error("操作失败" + e.message);
});
ElMessage.error('操作失败' + e.message)
})
})
.catch(() => {
ElMessage.info("操作被取消");
});
};
ElMessage.info('操作被取消')
})
}
const userEdit = function (row) {
user.value = row;
title.value = "编辑用户";
showUserEditDialog.value = true;
add.value = false;
};
user.value = row
title.value = '编辑用户'
showUserEditDialog.value = true
add.value = false
}
const addUser = () => {
user.value = { chat_id: 0, chat_roles: [], chat_models: [] };
title.value = "添加用户";
showUserEditDialog.value = true;
add.value = true;
};
user.value = { chat_id: 0, chat_roles: [], chat_models: [] }
title.value = '添加用户'
showUserEditDialog.value = true
add.value = true
}
const saveUser = function () {
userEditFormRef.value.validate((valid) => {
if (valid) {
showUserEditDialog.value = false;
console.log(user.value);
httpPost("/api/admin/user/save", user.value)
showUserEditDialog.value = false
console.log(user.value)
httpPost('/api/admin/user/save', user.value)
.then((res) => {
ElMessage.success("操作成功");
ElMessage.success('操作成功')
if (add.value) {
users.value.items.push(res.data);
users.value.items.push(res.data)
}
})
.catch((e) => {
ElMessage.error("操作失败" + e.message);
});
ElMessage.error('操作失败' + e.message)
})
} else {
return false;
return false
}
});
};
})
}
const userIds = ref([]);
const userIds = ref([])
const handleSelectionChange = function (rows) {
userIds.value = [];
userIds.value = []
rows.forEach((row) => {
userIds.value.push(row.id);
});
};
userIds.value.push(row.id)
})
}
const multipleDelete = function () {
ElMessageBox.confirm("此操作将会永久删除用户信息和聊天记录确认操作吗?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
ElMessageBox.confirm('此操作将会永久删除用户信息和聊天记录,确认操作吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
loading.value = true;
httpGet("/api/admin/user/remove", { ids: userIds.value })
loading.value = true
httpGet('/api/admin/user/remove', { ids: userIds.value })
.then(() => {
ElMessage.success("操作成功");
fetchUserList(users.value.page, users.value.page_size);
loading.value = false;
ElMessage.success('操作成功')
fetchUserList(users.value.page, users.value.page_size)
loading.value = false
})
.catch((e) => {
ElMessage.error("操作失败" + e.message);
loading.value = false;
});
ElMessage.error('操作失败' + e.message)
loading.value = false
})
})
.catch(() => {
ElMessage.info("操作被取消");
});
};
ElMessage.info('操作被取消')
})
}
// 重置密码
const resetPass = (row) => {
showResetPassDialog.value = true;
pass.value.id = row.id;
pass.value.username = row.username;
};
showResetPassDialog.value = true
pass.value.id = row.id
pass.value.username = row.username
}
const doResetPass = () => {
httpPost("/api/admin/user/resetPass", pass.value)
httpPost('/api/admin/user/resetPass', pass.value)
.then(() => {
ElMessage.success("操作成功");
showResetPassDialog.value = false;
ElMessage.success('操作成功')
showResetPassDialog.value = false
})
.catch((e) => {
ElMessage.error("操作失败" + e.message);
});
};
ElMessage.error('操作失败' + e.message)
})
}
// 生成登录链接
const showGenLoginLinkDialog = ref(false)
const loginLink = ref('')
const genLoginLink = (row) => {
showLoading()
httpGet('/api/admin/user/genLoginLink', { id: row.id })
.then((res) => {
loginLink.value = `${window.location.origin}/login?token=${res.data}`
showGenLoginLinkDialog.value = true
closeLoading()
})
.catch((e) => {
ElMessage.error('操作失败,' + e.message)
closeLoading()
})
}
const copyLoginLink = () => {
try {
navigator.clipboard.writeText(loginLink.value)
showMessageOK('复制成功!')
} catch (e) {
showMessageError('复制失败')
}
}
</script>
<style lang="stylus" scoped>

View File

@@ -3,8 +3,18 @@
<el-tabs v-model="activeName" @tab-change="handleChange">
<el-tab-pane label="对话列表" name="chat" v-loading="data.chat.loading">
<div class="handle-box">
<el-input v-model.number="data.chat.query.user_id" placeholder="账户ID" class="handle-input mr10" @keyup="searchChat($event)"></el-input>
<el-input v-model="data.chat.query.title" placeholder="对话标题" class="handle-input mr10" @keyup="searchChat($event)"></el-input>
<el-input
v-model.number="data.chat.query.user_id"
placeholder="账户ID"
class="handle-input mr10"
@keyup="searchChat($event)"
></el-input>
<el-input
v-model="data.chat.query.title"
placeholder="对话标题"
class="handle-input mr10"
@keyup="searchChat($event)"
></el-input>
<el-date-picker
v-model="data.chat.query.created_at"
type="daterange"
@@ -38,13 +48,15 @@
<el-table-column label="创建时间">
<template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span>
<span>{{ dateFormat(scope.row['created_at']) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="small" type="primary" @click="showMessages(scope.row)">查看</el-button>
<el-button size="small" type="primary" @click="showMessages(scope.row)"
>查看</el-button
>
<el-popconfirm title="确定要删除当前记录吗?" @confirm="removeChat(scope.row)">
<template #reference>
<el-button size="small" type="danger">删除</el-button>
@@ -70,9 +82,24 @@
</el-tab-pane>
<el-tab-pane label="消息记录" name="message">
<div class="handle-box">
<el-input v-model.number="data.message.query.user_id" placeholder="账户ID" class="handle-input mr10" @keyup="searchMessage($event)"></el-input>
<el-input v-model="data.message.query.content" placeholder="消息内容" class="handle-input mr10" @keyup="searchMessage($event)"></el-input>
<el-input v-model="data.message.query.model" placeholder="模型" class="handle-input mr10" @keyup="searchMessage($event)"></el-input>
<el-input
v-model.number="data.message.query.user_id"
placeholder="账户ID"
class="handle-input mr10"
@keyup="searchMessage($event)"
></el-input>
<el-input
v-model="data.message.query.content"
placeholder="消息内容"
class="handle-input mr10"
@keyup="searchMessage($event)"
></el-input>
<el-input
v-model="data.message.query.model"
placeholder="模型"
class="handle-input mr10"
@keyup="searchMessage($event)"
></el-input>
<el-date-picker
v-model="data.message.query.created_at"
type="daterange"
@@ -108,13 +135,15 @@
<el-table-column label="创建时间">
<template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span>
<span>{{ dateFormat(scope.row['created_at']) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="small" type="primary" @click="showContent(scope.row.content)">查看</el-button>
<el-button size="small" type="primary" @click="showContent(scope.row.content)"
>查看</el-button
>
<el-popconfirm title="确定要删除当前记录吗?" @confirm="removeMessage(scope.row)">
<template #reference>
<el-button size="small" type="danger">删除</el-button>
@@ -140,13 +169,23 @@
</el-tab-pane>
</el-tabs>
<el-dialog v-model="showContentDialog" title="消息详情" class="chat-dialog" style="--el-dialog-width: 60%">
<el-dialog
v-model="showContentDialog"
title="消息详情"
class="chat-dialog"
style="--el-dialog-width: 60%"
>
<div class="chat-detail">
<div class="chat-line" v-html="dialogContent"></div>
</div>
</el-dialog>
<el-dialog v-model="showChatItemDialog" title="对话详情" class="chat-dialog" style="--el-dialog-width: 60%">
<el-dialog
v-model="showChatItemDialog"
title="对话详情"
class="chat-dialog"
style="--el-dialog-width: 60%"
>
<div class="chat-box chat-page">
<div v-for="item in messages" :key="item.id">
<chat-prompt v-if="item.type === 'prompt'" :data="item" />
@@ -159,21 +198,21 @@
</template>
<script setup>
import { onMounted, ref } from "vue";
import { httpGet, httpPost } from "@/utils/http";
import { ElMessage } from "element-plus";
import { dateFormat, processContent } from "@/utils/libs";
import { Search } from "@element-plus/icons-vue";
import "highlight.js/styles/a11y-dark.css";
import hl from "highlight.js";
import ChatPrompt from "@/components/ChatPrompt.vue";
import ChatReply from "@/components/ChatReply.vue";
import ChatPrompt from '@/components/ChatPrompt.vue'
import ChatReply from '@/components/ChatReply.vue'
import { httpGet, httpPost } from '@/utils/http'
import { dateFormat, processContent } from '@/utils/libs'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import hl from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
import { onMounted, ref } from 'vue'
//
const data = ref({
chat: {
items: [],
query: { title: "", created_at: [], page: 1, page_size: 15 },
query: { title: '', created_at: [], page: 1, page_size: 15 },
total: 0,
page: 1,
pageSize: 15,
@@ -181,104 +220,104 @@ const data = ref({
},
message: {
items: [],
query: { title: "", created_at: [], page: 1, page_size: 15 },
query: { title: '', created_at: [], page: 1, page_size: 15 },
total: 0,
page: 1,
pageSize: 15,
loading: true,
},
});
const activeName = ref("chat");
})
const activeName = ref('chat')
onMounted(() => {
fetchChatData();
});
fetchChatData()
})
const handleChange = (tab) => {
if (tab === "chat") {
fetchChatData();
} else if (tab === "message") {
fetchMessageData();
if (tab === 'chat') {
fetchChatData()
} else if (tab === 'message') {
fetchMessageData()
}
};
}
//
const searchChat = (evt) => {
if (evt.keyCode === 13) {
fetchChatData();
fetchChatData()
}
};
}
//
const searchMessage = (evt) => {
if (evt.keyCode === 13) {
fetchMessageData();
fetchMessageData()
}
};
}
//
const fetchChatData = () => {
const d = data.value.chat;
d.query.page = d.page;
d.query.page_size = d.pageSize;
httpPost("/api/admin/chat/list", d.query)
const d = data.value.chat
d.query.page = d.page
d.query.page_size = d.pageSize
httpPost('/api/admin/chat/list', d.query)
.then((res) => {
if (res.data) {
d.items = res.data.items;
d.total = res.data.total;
d.page = res.data.page;
d.pageSize = res.data.page_size;
d.items = res.data.items
d.total = res.data.total
d.page = res.data.page
d.pageSize = res.data.page_size
}
d.loading = false;
d.loading = false
})
.catch((e) => {
ElMessage.error("获取数据失败:" + e.message);
});
};
ElMessage.error('获取数据失败:' + e.message)
})
}
const fetchMessageData = () => {
const d = data.value.message;
d.query.page = d.page;
d.query.page_size = d.pageSize;
httpPost("/api/admin/chat/message", d.query)
const d = data.value.message
d.query.page = d.page
d.query.page_size = d.pageSize
httpPost('/api/admin/chat/message', d.query)
.then((res) => {
if (res.data) {
d.items = res.data.items;
d.total = res.data.total;
d.page = res.data.page;
d.pageSize = res.data.page_size;
d.items = res.data.items
d.total = res.data.total
d.page = res.data.page
d.pageSize = res.data.page_size
}
d.loading = false;
d.loading = false
})
.catch((e) => {
ElMessage.error("获取数据失败:" + e.message);
});
};
ElMessage.error('获取数据失败:' + e.message)
})
}
const removeChat = function (row) {
httpGet("/api/admin/chat/remove?chat_id=" + row.chat_id)
httpGet('/api/admin/chat/remove?chat_id=' + row.chat_id)
.then(() => {
ElMessage.success("删除成功!");
fetchChatData();
ElMessage.success('删除成功!')
fetchChatData()
})
.catch((e) => {
ElMessage.error("删除失败:" + e.message);
});
};
ElMessage.error('删除失败:' + e.message)
})
}
const removeMessage = function (row) {
httpGet("/api/admin/chat/message/remove?id=" + row.id)
httpGet('/api/admin/chat/message/remove?id=' + row.id)
.then(() => {
ElMessage.success("删除成功!");
fetchMessageData();
ElMessage.success('删除成功!')
fetchMessageData()
})
.catch((e) => {
ElMessage.error("删除失败:" + e.message);
});
};
ElMessage.error('删除失败:' + e.message)
})
}
const mathjaxPlugin = require("markdown-it-mathjax3");
const md = require("markdown-it")({
const mathjaxPlugin = require('markdown-it-mathjax3')
const md = require('markdown-it')({
breaks: true,
html: true,
linkify: true,
@@ -286,43 +325,43 @@ const md = require("markdown-it")({
highlight: function (str, lang) {
if (lang && hl.getLanguage(lang)) {
//
const preCode = hl.highlight(lang, str, true).value;
const preCode = hl.highlight(lang, str, true).value
// pre
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code></pre>`;
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code></pre>`
}
//
const preCode = md.utils.escapeHtml(str);
const preCode = md.utils.escapeHtml(str)
// pre
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code></pre>`;
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code></pre>`
},
});
md.use(mathjaxPlugin);
})
md.use(mathjaxPlugin)
const showContentDialog = ref(false);
const dialogContent = ref("");
const showContentDialog = ref(false)
const dialogContent = ref('')
const showContent = (content) => {
showContentDialog.value = true;
dialogContent.value = md.render(processContent(content));
};
showContentDialog.value = true
dialogContent.value = md.render(processContent(content))
}
const showChatItemDialog = ref(false);
const messages = ref([]);
const showChatItemDialog = ref(false)
const messages = ref([])
const showMessages = (row) => {
showChatItemDialog.value = true;
messages.value = [];
httpGet("/api/admin/chat/history?chat_id=" + row.chat_id)
showChatItemDialog.value = true
messages.value = []
httpGet('/api/admin/chat/history?chat_id=' + row.chat_id)
.then((res) => {
const data = res.data;
const data = res.data
for (let i = 0; i < data.length; i++) {
messages.value.push(data[i]);
messages.value.push(data[i])
}
})
.catch((e) => {
// TODO:
ElMessage.error("加载聊天记录失败:" + e.message);
});
};
ElMessage.error('加载聊天记录失败:' + e.message)
})
}
</script>
<style lang="stylus" scoped>
@@ -380,7 +419,7 @@ const showMessages = (row) => {
font-size: 14px;
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,268 @@
<template>
<div class="apps-page">
<van-nav-bar title="全部应用" left-arrow @click-left="router.back()" />
<div class="apps-filter mb-8 pt-8" style="border: 1px solid #ccc">
<van-tabs v-model="activeTab" animated swipeable>
<van-tab title="全部分类">
<div class="app-list">
<van-list v-model="loading" :finished="true" finished-text="" @load="fetchApps()">
<van-cell v-for="item in apps" :key="item.id" class="app-cell">
<div class="app-card">
<div class="app-info">
<div class="app-image">
<van-image :src="item.icon" round />
</div>
<div class="app-detail">
<div class="app-title">{{ item.name }}</div>
<div class="app-desc">{{ item.hello_msg }}</div>
</div>
</div>
<div class="app-actions">
<van-button
size="small"
type="primary"
class="action-btn"
@click="useRole(item.id)"
>对话</van-button
>
<van-button
size="small"
:type="hasRole(item.key) ? 'danger' : 'success'"
class="action-btn"
@click="updateRole(item, hasRole(item.key) ? 'remove' : 'add')"
>
{{ hasRole(item.key) ? '移除' : '添加' }}
</van-button>
</div>
</div>
</van-cell>
</van-list>
</div>
</van-tab>
<van-tab v-for="type in appTypes" :key="type.id" :title="type.name">
<div class="app-list">
<van-list
v-model="loading"
:finished="true"
finished-text=""
@load="fetchApps(type.id)"
>
<van-cell v-for="item in typeApps" :key="item.id" class="app-cell">
<div class="app-card">
<div class="app-info">
<div class="app-image">
<van-image :src="item.icon" round />
</div>
<div class="app-detail">
<div class="app-title">{{ item.name }}</div>
<div class="app-desc">{{ item.hello_msg }}</div>
</div>
</div>
<div class="app-actions">
<van-button
size="small"
type="primary"
class="action-btn"
@click="useRole(item.id)"
>对话</van-button
>
<van-button
size="small"
:type="hasRole(item.key) ? 'danger' : 'success'"
class="action-btn"
@click="updateRole(item, hasRole(item.key) ? 'remove' : 'add')"
>
{{ hasRole(item.key) ? '移除' : '添加' }}
</van-button>
</div>
</div>
</van-cell>
</van-list>
</div>
</van-tab>
</van-tabs>
</div>
</div>
</template>
<script setup>
import { checkSession } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
import { showNotify } from 'vant'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const isLogin = ref(false)
const apps = ref([])
const typeApps = ref([])
const appTypes = ref([])
const loading = ref(false)
const roles = ref([])
const activeTab = ref(0)
onMounted(() => {
checkSession()
.then((user) => {
isLogin.value = true
roles.value = user.chat_roles
})
.catch(() => {})
fetchAppTypes()
fetchApps()
})
const fetchAppTypes = () => {
httpGet('/api/app/type/list')
.then((res) => {
appTypes.value = res.data
})
.catch((e) => {
showNotify({ type: 'danger', message: '获取应用分类失败:' + e.message })
})
}
const fetchApps = (typeId = '') => {
httpGet('/api/app/list', { tid: typeId })
.then((res) => {
const items = res.data
// 处理 hello message
for (let i = 0; i < items.length; i++) {
items[i].intro = substr(items[i].hello_msg, 80)
}
if (typeId) {
typeApps.value = items
} else {
apps.value = items
}
})
.catch((e) => {
showNotify({ type: 'danger', message: '获取应用失败:' + e.message })
})
}
const updateRole = (row, opt) => {
if (!isLogin.value) {
return showLoginDialog(router)
}
const title = ref('')
if (opt === 'add') {
title.value = '添加应用'
const exists = arrayContains(roles.value, row.key)
if (exists) {
return
}
roles.value.push(row.key)
} else {
title.value = '移除应用'
const exists = arrayContains(roles.value, row.key)
if (!exists) {
return
}
roles.value = removeArrayItem(roles.value, row.key)
}
httpPost('/api/app/update', { keys: roles.value })
.then(() => {
showNotify({ type: 'success', message: title.value + '成功!' })
})
.catch((e) => {
showNotify({ type: 'danger', message: title.value + '失败:' + e.message })
})
}
const hasRole = (roleKey) => {
return arrayContains(roles.value, roleKey, (v1, v2) => v1 === v2)
}
const useRole = (roleId) => {
if (!isLogin.value) {
return showLoginDialog(router)
}
router.push(`/mobile/chat/session?role_id=${roleId}`)
}
</script>
<style scoped lang="stylus">
.apps-page {
min-height 100vh
background-color var(--van-background)
.apps-filter {
padding 10px 0
:deep(.van-tabs__nav) {
background var(--van-background-2)
}
}
.app-list {
padding 0 15px
.app-cell {
padding 0
margin-bottom 15px
.app-card {
background var(--van-cell-background)
border-radius 12px
padding 15px
box-shadow 0 2px 12px rgba(0, 0, 0, 0.05)
.app-info {
display flex
align-items center
margin-bottom 15px
.app-image {
width 60px
height 60px
margin-right 15px
:deep(.van-image) {
width 100%
height 100%
}
}
.app-detail {
flex 1
.app-title {
font-size 16px
font-weight 600
margin-bottom 5px
color var(--van-text-color)
}
.app-desc {
font-size 13px
color var(--van-gray-6)
display -webkit-box
-webkit-box-orient vertical
-webkit-line-clamp 2
overflow hidden
}
}
}
.app-actions {
display flex
gap 10px
.action-btn {
flex 1
border-radius 20px
padding 0 10px
}
}
}
}
}
}
</style>

View File

@@ -1,60 +1,79 @@
<template>
<div class="index container">
<h2 class="title">{{ title }}</h2>
<van-notice-bar left-icon="info-o" :scrollable="true">{{ slogan }}}</van-notice-bar>
<div class="header">
<h2 class="title">{{ title }}</h2>
</div>
<div class="content">
<van-grid :column-num="3" :gutter="10" border>
<van-grid-item @click="router.push('chat')">
<template #icon>
<i class="iconfont icon-chat"></i>
</template>
<template #text>
<div class="text">AI 对话</div>
</template>
</van-grid-item>
<div class="content mb-8">
<div class="feature-grid">
<van-grid :column-num="3" :gutter="15" border>
<van-grid-item @click="router.push('chat')" class="feature-item">
<template #icon>
<div class="feature-icon">
<i class="iconfont icon-chat"></i>
</div>
</template>
<template #text>
<div class="text">AI 对话</div>
</template>
</van-grid-item>
<van-grid-item @click="router.push('image')">
<template #icon>
<i class="iconfont icon-mj"></i>
</template>
<template #text>
<div class="text">AI 绘画</div>
</template>
</van-grid-item>
<van-grid-item @click="router.push('image')" class="feature-item">
<template #icon>
<div class="feature-icon">
<i class="iconfont icon-mj"></i>
</div>
</template>
<template #text>
<div class="text">AI 绘画</div>
</template>
</van-grid-item>
<van-grid-item @click="router.push('imgWall')">
<template #icon>
<van-icon name="photo-o" />
</template>
<template #text>
<div class="text">AI 画廊</div>
</template>
</van-grid-item>
</van-grid>
<van-grid-item @click="router.push('imgWall')" class="feature-item">
<template #icon>
<div class="feature-icon">
<van-icon name="photo-o" />
</div>
</template>
<template #text>
<div class="text">AI 画廊</div>
</template>
</van-grid-item>
</van-grid>
</div>
<div class="section-header">
<h3 class="section-title">推荐应用</h3>
<van-button class="more-btn" size="small" icon="arrow" @click="router.push('apps')"
>更多</van-button
>
</div>
<div class="app-list">
<van-list v-model:loading="loading" :finished="true" finished-text="" @load="fetchApps">
<van-cell v-for="item in apps" :key="item.id">
<div>
<div class="item">
<div class="image flex justify-center items-center">
<van-image :src="item.icon" />
<van-list v-model="loading" :finished="true" finished-text="" @load="fetchApps">
<van-cell v-for="item in displayApps" :key="item.id" class="app-cell">
<div class="app-card">
<div class="app-info">
<div class="app-image">
<van-image :src="item.icon" round />
</div>
<div class="info">
<div class="info-title">{{ item.name }}</div>
<div class="info-text">{{ item.hello_msg }}</div>
<div class="app-detail">
<div class="app-title">{{ item.name }}</div>
<div class="app-desc">{{ item.hello_msg }}</div>
</div>
</div>
<div class="btn">
<div v-if="hasRole(item.key)">
<van-button size="small" type="success" @click="useRole(item.id)">使用</van-button>
<van-button size="small" type="danger" @click="updateRole(item, 'remove')">移除</van-button>
</div>
<van-button v-else size="small" style="--el-color-primary: #009999" @click="updateRole(item, 'add')">
<van-icon name="add-o" />
<span>添加应用</span>
<div class="app-actions">
<van-button size="small" type="primary" class="action-btn" @click="useRole(item.id)"
>对话</van-button
>
<van-button
size="small"
:type="hasRole(item.key) ? 'danger' : 'success'"
class="action-btn"
@click="updateRole(item, hasRole(item.key) ? 'remove' : 'add')"
>
{{ hasRole(item.key) ? '移除' : '添加' }}
</van-button>
</div>
</div>
@@ -66,173 +85,245 @@
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { checkSession, getSystemInfo } from "@/store/cache";
import { httpGet, httpPost } from "@/utils/http";
import { arrayContains, removeArrayItem, showLoginDialog, substr } from "@/utils/libs";
import { showNotify } from "vant";
import { ElMessage } from "element-plus";
import { checkSession, getSystemInfo } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import { showNotify } from 'vant'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const title = ref(process.env.VUE_APP_TITLE);
const router = useRouter();
const isLogin = ref(false);
const apps = ref([]);
const loading = ref(false);
const roles = ref([]);
const slogan = ref("你有多大想象力AI就有多大创造力");
const title = ref(process.env.VUE_APP_TITLE)
const router = useRouter()
const isLogin = ref(false)
const apps = ref([])
const loading = ref(false)
const roles = ref([])
const slogan = ref('你有多大想象力AI就有多大创造力')
// 只显示前5个应用
const displayApps = computed(() => {
return apps.value.slice(0, 8)
})
onMounted(() => {
getSystemInfo()
.then((res) => {
title.value = res.data.title;
title.value = res.data.title
if (res.data.slogan) {
slogan.value = res.data.slogan;
slogan.value = res.data.slogan
}
})
.catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message);
});
ElMessage.error('获取系统配置失败:' + e.message)
})
checkSession()
.then((user) => {
isLogin.value = true;
roles.value = user.chat_roles;
isLogin.value = true
roles.value = user.chat_roles
})
.catch(() => {});
fetchApps();
});
.catch(() => {})
fetchApps()
})
const fetchApps = () => {
httpGet("/api/app/list")
httpGet('/api/app/list')
.then((res) => {
const items = res.data;
const items = res.data
// 处理 hello message
for (let i = 0; i < items.length; i++) {
items[i].intro = substr(items[i].hello_msg, 80);
items[i].intro = substr(items[i].hello_msg, 80)
}
apps.value = items;
apps.value = items
})
.catch((e) => {
showNotify({ type: "danger", message: "获取应用失败:" + e.message });
});
};
showNotify({ type: 'danger', message: '获取应用失败:' + e.message })
})
}
const updateRole = (row, opt) => {
if (!isLogin.value) {
return showLoginDialog(router);
return showLoginDialog(router)
}
const title = ref("");
if (opt === "add") {
title.value = "添加应用";
const exists = arrayContains(roles.value, row.key);
const title = ref('')
if (opt === 'add') {
title.value = '添加应用'
const exists = arrayContains(roles.value, row.key)
if (exists) {
return;
return
}
roles.value.push(row.key);
roles.value.push(row.key)
} else {
title.value = "移除应用";
const exists = arrayContains(roles.value, row.key);
title.value = '移除应用'
const exists = arrayContains(roles.value, row.key)
if (!exists) {
return;
return
}
roles.value = removeArrayItem(roles.value, row.key);
roles.value = removeArrayItem(roles.value, row.key)
}
httpPost("/api/role/update", { keys: roles.value })
httpPost('/api/app/update', { keys: roles.value })
.then(() => {
ElMessage.success({ message: title.value + "成功!", duration: 1000 });
ElMessage.success({ message: title.value + '成功!', duration: 1000 })
})
.catch((e) => {
ElMessage.error(title.value + "失败:" + e.message);
});
};
ElMessage.error(title.value + '失败:' + e.message)
})
}
const hasRole = (roleKey) => {
return arrayContains(roles.value, roleKey, (v1, v2) => v1 === v2);
};
return arrayContains(roles.value, roleKey, (v1, v2) => v1 === v2)
}
const useRole = (roleId) => {
if (!isLogin.value) {
return showLoginDialog(router);
return showLoginDialog(router)
}
router.push(`/mobile/chat/session?role_id=${roleId}`);
};
router.push(`/mobile/chat/session?role_id=${roleId}`)
}
</script>
<style scoped lang="stylus">
.index {
color var(--van-text-color)
.title {
display flex
justify-content center
background-color var(--van-background)
min-height 100vh
display flex
flex-direction column
.header {
flex-shrink 0
padding 10px 15px
text-align center
background var(--van-background)
position sticky
top 0
z-index 1
.title {
font-size 24px
font-weight 600
color var(--van-text-color)
}
.slogan {
font-size 14px
color var(--van-gray-6)
}
}
--van-notice-bar-font-size: 16px
.content {
padding 15px 0 60px 0
.van-grid-item {
.iconfont {
font-size 20px
flex 1
overflow-y auto
padding 15px
-webkit-overflow-scrolling touch
.feature-grid {
margin-bottom 30px
.feature-item {
padding 15px 0
.feature-icon {
width 50px
height 50px
border-radius 50%
background var(--van-primary-color)
display flex
align-items center
justify-content center
margin-bottom 10px
i, .van-icon {
font-size 24px
color white
}
}
.text {
font-size 14px
font-weight 500
}
}
.text {
display flex
width 100%
padding 10px
justify-content center
font-size 14px
}
.section-header {
display flex
justify-content space-between
align-items center
margin-bottom 15px
.section-title {
font-size 18px
font-weight 600
color var(--van-text-color)
}
.more-btn {
padding 0 10px
font-size 12px
border-radius 15px
}
}
.app-list {
padding-top 10px
.app-cell {
padding 0
margin-bottom 15px
.item {
display flex
.image {
width 80px
height 80px
min-width 80px
border-radius 5px
overflow hidden
}
.app-card {
background var(--van-cell-background)
border-radius 12px
padding 15px
box-shadow 0 2px 12px rgba(0, 0, 0, 0.05)
.info {
text-align left
padding 0 10px
.info-title {
color var(--van-text-color)
font-size 1.25rem
line-height 1.75rem
letter-spacing: .025em;
font-weight: 600;
word-break: break-all;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
.app-info {
display flex
align-items center
margin-bottom 15px
.app-image {
width 60px
height 60px
margin-right 15px
:deep(.van-image) {
width 100%
height 100%
}
}
.app-detail {
flex 1
.app-title {
font-size 16px
font-weight 600
margin-bottom 5px
color var(--van-text-color)
}
.app-desc {
font-size 13px
color var(--van-gray-6)
display -webkit-box
-webkit-box-orient vertical
-webkit-line-clamp 2
overflow hidden
}
}
}
.info-text {
padding 5px 0
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
word-break: break-all;
font-size: .875rem;
}
}
}
.app-actions {
display flex
gap 10px
.btn {
padding 5px 0
.van-button {
margin-right 10px
.van-icon {
margin-right 5px
.action-btn {
flex 1
border-radius 20px
padding 0 10px
}
}
}
}

View File

@@ -24,6 +24,9 @@ module.exports = defineConfig({
outputDir: "dist",
crossorigin: "anonymous",
devServer: {
client: {
overlay: false // 关闭错误覆盖层
},
allowedHosts: "all",
port: 8888,
proxy: {