Compare commits

..

8 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
9d8dd68b59 Implement streaming output for Dify provider
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-11-09 17:52:18 +00:00
copilot-swe-agent[bot]
e99258a276 Initial plan 2025-11-09 17:47:46 +00:00
Soulter
6d00717655 feat: add streaming support with toggle in chat interface and adjust layout for mobile 2025-11-09 21:57:30 +08:00
Soulter
bb5f06498e perf: refine login page 2025-11-09 20:57:45 +08:00
Dt8333
aca5743ab6 feat: 为部分适配器添加缺失的 send_streaming 方法 (#3545)
为Wechatpadpro和discord添加缺失的方法。
2025-11-09 16:00:24 +08:00
Soulter
6903032f7e fix: improve knowledge base chip display with truncation and styling (#3582)
fixes: #3546
2025-11-09 15:30:41 +08:00
nazo
1ce0ff87bd feat: supports to add custom headers for openai providers (#3581)
* feat: OPENAI系支持自定义添加请求头

* chore: add custom headers and extra body to config for zhipu

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-11-09 15:12:52 +08:00
Soulter
e39d6bae0b fix: update JSON submission link in plugin publish template 2025-11-09 15:06:40 +08:00
27 changed files with 578 additions and 576 deletions

View File

@@ -16,7 +16,7 @@ body:
请将插件信息填写到下方的 JSON 代码块中。其中 `tags`(插件标签)和 `social_link`(社交链接)选填。
不熟悉 JSON ?可以从 [此](https://plugins.astrbot.app/submit) 生成 JSON ,生成后记得复制粘贴过来.
不熟悉 JSON ?可以从 [此](https://plugins.astrbot.app) 右下角提交。
- type: textarea
id: plugin-info

View File

@@ -8,7 +8,7 @@
<div>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=1" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
<br>

View File

@@ -740,6 +740,7 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.openai.com/v1",
"timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
@@ -755,6 +756,7 @@ CONFIG_METADATA_2 = {
"api_base": "",
"timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -768,6 +770,7 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"xai_native_search": False,
"modalities": ["text", "image", "tool_use"],
@@ -799,6 +802,7 @@ CONFIG_METADATA_2 = {
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://localhost:11434/v1",
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -813,6 +817,7 @@ CONFIG_METADATA_2 = {
"model_config": {
"model": "llama-3.1-8b",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -829,6 +834,7 @@ CONFIG_METADATA_2 = {
"model": "gemini-1.5-flash",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -870,6 +876,7 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.deepseek.com/v1",
"timeout": 120,
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
},
@@ -883,6 +890,7 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.302.ai/v1",
"timeout": 120,
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -899,6 +907,7 @@ CONFIG_METADATA_2 = {
"model": "deepseek-ai/DeepSeek-V3",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -915,6 +924,7 @@ CONFIG_METADATA_2 = {
"model": "deepseek/deepseek-r1",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
},
"小马算力": {
@@ -930,6 +940,7 @@ CONFIG_METADATA_2 = {
"model": "kimi-k2-instruct-0905",
"temperature": 0.7,
},
"custom_headers": {},
"custom_extra_body": {},
},
"优云智算": {
@@ -944,6 +955,7 @@ CONFIG_METADATA_2 = {
"model_config": {
"model": "moonshotai/Kimi-K2-Instruct",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -957,6 +969,7 @@ CONFIG_METADATA_2 = {
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -972,6 +985,8 @@ CONFIG_METADATA_2 = {
"model_config": {
"model": "glm-4-flash",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Dify": {
@@ -1028,6 +1043,7 @@ CONFIG_METADATA_2 = {
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
@@ -1040,6 +1056,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.fastgpt.in/api/v1",
"timeout": 60,
"custom_headers": {},
"custom_extra_body": {},
},
"Whisper(API)": {
@@ -1321,6 +1338,12 @@ CONFIG_METADATA_2 = {
"render_type": "checkbox",
"hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。",
},
"custom_headers": {
"description": "自定义添加请求头",
"type": "dict",
"items": {},
"hint": "此处添加的键值对将被合并到 OpenAI SDK 的 default_headers 中,用于自定义 HTTP 请求头。值必须为字符串。",
},
"custom_extra_body": {
"description": "自定义请求体参数",
"type": "dict",

View File

@@ -429,6 +429,10 @@ class LLMRequestSubStage(Stage):
logger.error(f"选择的提供商类型无效({type(provider)}),跳过 LLM 请求处理。")
return
streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
streaming_response = bool(enable_streaming)
if event.get_extra("provider_request"):
req = event.get_extra("provider_request")
assert isinstance(req, ProviderRequest), (
@@ -548,7 +552,7 @@ class LLMRequestSubStage(Stage):
provider=provider,
first_provider_request=req,
curr_provider_request=req,
streaming=self.streaming_response,
streaming=streaming_response,
event=event,
)
await agent_runner.reset(
@@ -560,10 +564,10 @@ class LLMRequestSubStage(Stage):
),
tool_executor=FunctionToolExecutor(),
agent_hooks=MAIN_AGENT_HOOKS,
streaming=self.streaming_response,
streaming=streaming_response,
)
if self.streaming_response:
if streaming_response:
# 流式响应
event.set_result(
MessageEventResult()

View File

@@ -1,7 +1,7 @@
import asyncio
import base64
import binascii
import sys
from collections.abc import AsyncGenerator
from io import BytesIO
from pathlib import Path
@@ -21,11 +21,6 @@ from astrbot.api.platform import AstrBotMessage, At, PlatformMetadata
from .client import DiscordBotClient
from .components import DiscordEmbed, DiscordView
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
# 自定义Discord视图组件兼容旧版本
class DiscordViewComponent(BaseMessageComponent):
@@ -49,7 +44,6 @@ class DiscordPlatformEvent(AstrMessageEvent):
self.client = client
self.interaction_followup_webhook = interaction_followup_webhook
@override
async def send(self, message: MessageChain):
"""发送消息到Discord平台"""
# 解析消息链为 Discord 所需的对象
@@ -98,6 +92,21 @@ class DiscordPlatformEvent(AstrMessageEvent):
await super().send(message)
async def send_streaming(
self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
):
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
return None
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
async def _get_channel(self) -> discord.abc.Messageable | None:
"""获取当前事件对应的频道对象"""
try:

View File

@@ -163,6 +163,9 @@ class WebChatAdapter(Platform):
_, _, payload = message.raw_message # type: ignore
message_event.set_extra("selected_provider", payload.get("selected_provider"))
message_event.set_extra("selected_model", payload.get("selected_model"))
message_event.set_extra(
"enable_streaming", payload.get("enable_streaming", True)
)
self.commit_event(message_event)

View File

@@ -1,6 +1,7 @@
import asyncio
import base64
import io
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
import aiohttp
@@ -50,6 +51,21 @@ class WeChatPadProMessageEvent(AstrMessageEvent):
await self._send_voice(session, comp)
await super().send(message)
async def send_streaming(
self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
):
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
return None
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
async def _send_image(self, session: aiohttp.ClientSession, comp: Image):
b64 = await comp.convert_to_base64()
raw = self._validate_base64(b64)

View File

@@ -205,21 +205,165 @@ class ProviderDify(Provider):
model=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")
# 调用 text_chat 模拟流式
llm_response = await self.text_chat(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
)
llm_response.is_chunk = True
yield llm_response
llm_response.is_chunk = False
yield llm_response
if image_urls is None:
image_urls = []
session_id = session_id or kwargs.get("user") or "unknown"
conversation_id = self.conversation_ids.get(session_id, "")
files_payload = []
for image_url in image_urls:
image_path = (
await download_image_by_url(image_url)
if image_url.startswith("http")
else image_url
)
file_response = await self.api_client.file_upload(
image_path,
user=session_id,
)
logger.debug(f"Dify 上传图片响应:{file_response}")
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。",
)
continue
files_payload.append(
{
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
},
)
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_var = await sp.session_get(session_id, "session_variables", default={})
payload_vars.update(session_var)
payload_vars["system_prompt"] = system_prompt
try:
match self.api_type:
case "chat" | "agent" | "chatflow":
if not prompt:
prompt = "请描述这张图片。"
accumulated_text = ""
async for chunk in self.api_client.chat_messages(
inputs={
**payload_vars,
},
query=prompt,
user=session_id,
conversation_id=conversation_id,
files=files_payload,
timeout=self.timeout,
):
logger.debug(f"dify resp chunk: {chunk}")
if (
chunk["event"] == "message"
or chunk["event"] == "agent_message"
):
accumulated_text += chunk["answer"]
if not conversation_id:
self.conversation_ids[session_id] = chunk[
"conversation_id"
]
conversation_id = chunk["conversation_id"]
# Yield streaming chunk
llm_response = LLMResponse(
role="assistant",
result_chain=MessageChain(
chain=[Comp.Plain(chunk["answer"])]
),
is_chunk=True,
)
yield llm_response
elif chunk["event"] == "message_end":
logger.debug("Dify message end")
break
elif chunk["event"] == "error":
logger.error(f"Dify 出现错误:{chunk}")
yield LLMResponse(
role="err",
completion_text=f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}",
)
return
# Yield final complete result
chain = MessageChain(chain=[Comp.Plain(accumulated_text)])
yield LLMResponse(
role="assistant", result_chain=chain, is_chunk=False
)
case "workflow":
workflow_result = None
async for chunk in self.api_client.workflow_run(
inputs={
self.dify_query_input_key: prompt,
"astrbot_session_id": session_id,
**payload_vars,
},
user=session_id,
files=files_payload,
timeout=self.timeout,
):
match chunk["event"]:
case "workflow_started":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。",
)
case "node_finished":
logger.debug(
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。",
)
case "workflow_finished":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束",
)
logger.debug(f"Dify 工作流结果:{chunk}")
if chunk["data"]["error"]:
logger.error(
f"Dify 工作流出现错误:{chunk['data']['error']}",
)
yield LLMResponse(
role="err",
completion_text=f"Dify 工作流出现错误:{chunk['data']['error']}",
)
return
if (
self.workflow_output_key
not in chunk["data"]["outputs"]
):
yield LLMResponse(
role="err",
completion_text=f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}",
)
return
workflow_result = chunk
if workflow_result:
chain = await self.parse_dify_result(workflow_result)
yield LLMResponse(
role="assistant", result_chain=chain, is_chunk=False
)
else:
logger.warning("Dify 工作流请求结果为空,请查看 Debug 日志。")
yield LLMResponse(
role="err", completion_text="Dify 工作流请求结果为空"
)
case _:
yield LLMResponse(
role="err",
completion_text=f"未知的 Dify API 类型:{self.api_type}",
)
except Exception as e:
logger.error(f"Dify 请求失败:{e!s}")
yield LLMResponse(role="err", completion_text=f"Dify 请求失败:{e!s}")
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
if isinstance(chunk, str):

View File

@@ -43,14 +43,23 @@ class ProviderOpenAIOfficial(Provider):
self.api_keys: list = super().get_keys()
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
self.timeout = provider_config.get("timeout", 120)
self.custom_headers = provider_config.get("custom_headers", {})
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
if not isinstance(self.custom_headers, dict) or not self.custom_headers:
self.custom_headers = None
else:
for key in self.custom_headers:
self.custom_headers[key] = str(self.custom_headers[key])
# 适配 azure openai #332
if "api_version" in provider_config:
# 使用 azure api
self.client = AsyncAzureOpenAI(
api_key=self.chosen_api_key,
api_version=provider_config.get("api_version", None),
default_headers=self.custom_headers,
base_url=provider_config.get("api_base", ""),
timeout=self.timeout,
)
@@ -59,6 +68,7 @@ class ProviderOpenAIOfficial(Provider):
self.client = AsyncOpenAI(
api_key=self.chosen_api_key,
base_url=provider_config.get("api_base", None),
default_headers=self.custom_headers,
timeout=self.timeout,
)

View File

@@ -125,6 +125,8 @@ class ChatRoute(Route):
audio_url = post_data.get("audio_url")
selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model")
enable_streaming = post_data.get("enable_streaming", True) # 默认为 True
if not message and not image_url and not audio_url:
return (
Response()
@@ -224,6 +226,7 @@ class ChatRoute(Route):
"audio_url": audio_url,
"selected_provider": selected_provider,
"selected_model": selected_model,
"enable_streaming": enable_streaming,
},
),
)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,55 +1,70 @@
<template>
<v-card class="chat-page-card">
<v-card class="chat-page-card" elevation="0" rounded="0">
<v-card-text class="chat-page-container">
<!-- 遮罩层 (手机端) -->
<div class="mobile-overlay" v-if="isMobile && mobileMenuOpen" @click="closeMobileSidebar"></div>
<div class="chat-layout">
<div class="sidebar-panel" :class="{ 'sidebar-collapsed': sidebarCollapsed }"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f5f5f5' }"
<div class="sidebar-panel"
:class="{
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }"
@mouseenter="handleSidebarMouseEnter" @mouseleave="handleSidebarMouseLeave">
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
v-if="chatboxMode">
<img width="50" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<span v-if="!sidebarCollapsed"
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
</div>
<div class="sidebar-collapse-btn-container">
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text"
color="deep-purple">
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<!-- 手机端关闭按钮 -->
<div class="sidebar-collapse-btn-container" v-if="isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="closeMobileSidebar" variant="text"
color="deep-purple">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div style="padding: 16px; padding-top: 8px;">
<v-btn block variant="text" class="new-chat-btn" @click="newC" :disabled="!currCid"
v-if="!sidebarCollapsed" prepend-icon="mdi-plus"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
style="background-color: transparent !important; border-radius: 4px;">{{
tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="newC" :disabled="!currCid" v-if="sidebarCollapsed"
<v-btn icon="mdi-plus" rounded="lg" @click="newC" :disabled="!currCid" v-if="sidebarCollapsed && !isMobile"
elevation="0"></v-btn>
</div>
<div v-if="!sidebarCollapsed">
<div v-if="!sidebarCollapsed || isMobile">
<v-divider class="mx-4"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed">
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="conversations.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" v-model:selected="selectedConversations"
@update:selected="getConversationMessages">
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed" class="conversation-title">{{ item.title
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">{{ item.title
|| tm('conversation.newConversation') }}</v-list-item-title>
<v-list-item-subtitle v-if="!sidebarCollapsed" class="timestamp">{{
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">{{
formatDate(item.updated_at)
}}</v-list-item-subtitle>
}}</v-list-item-subtitle>
<template v-if="!sidebarCollapsed" v-slot:append>
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@@ -66,7 +81,7 @@
<v-fade-transition>
<div class="no-conversations" v-if="conversations.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded">
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
{{ tm('conversation.noHistory') }}</div>
</div>
</v-fade-transition>
@@ -78,12 +93,17 @@
<div class="chat-content-panel">
<div class="conversation-header fade-in">
<div v-if="currCid && getCurrentConversation">
<!-- 手机端菜单按钮 -->
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" v-if="isMobile" variant="text">
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- <div v-if="currCid && getCurrentConversation">
<h3
style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ getCurrentConversation.title || tm('conversation.newConversation') }}</h3>
<span style="font-size: 12px;">{{ formatDate(getCurrentConversation.updated_at) }}</span>
</div>
</div> -->
<div class="conversation-header-actions">
<!-- router 推送到 /chatbox -->
<v-tooltip :text="tm('actions.fullscreen')" v-if="!chatboxMode">
@@ -117,7 +137,6 @@
</v-tooltip>
</div>
</div>
<v-divider v-if="currCid && getCurrentConversation" class="conversation-divider"></v-divider>
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
@@ -146,17 +165,30 @@
<!-- 输入区域 -->
<div class="input-area fade-in">
<div
<div class="input-container"
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
<textarea id="input-field" v-model="prompt" @keydown="handleInputKeyDown"
:disabled="isStreaming" @click:clear="clearMessage"
placeholder="Ask AstrBot..."
:disabled="isStreaming" @click:clear="clearMessage" placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 0px 8px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px;">
style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
<div
style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<!-- 选择提供商和模型 -->
<ProviderModelSelector ref="providerModelSelector" />
<!-- 流式响应开关 -->
<v-tooltip
:text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')"
location="top">
<template v-slot:activator="{ props }">
<v-chip v-bind="props" @click="toggleStreaming" size="x-small"
class="streaming-toggle-chip">
<v-icon start :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'"
size="small"></v-icon>
{{ enableStreaming ? tm('streaming.on') : tm('streaming.off') }}
</v-chip>
</template>
</v-tooltip>
</div>
<div
style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
@@ -175,7 +207,6 @@
class="send-btn" size="small" />
</div>
</div>
</div>
<!-- 附件预览区 -->
@@ -242,6 +273,7 @@ import ProviderModelSelector from '@/components/chat/ProviderModelSelector.vue';
import MessageList from '@/components/chat/MessageList.vue';
import 'highlight.js/styles/github.css';
import { useToast } from '@/utils/toast';
import { useTheme } from 'vuetify';
export default {
name: 'ChatPage',
@@ -258,10 +290,12 @@ export default {
}, setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const theme = useTheme();
return {
t,
tm,
theme,
router,
ref
};
@@ -287,7 +321,7 @@ export default {
// Ctrl键长按相关变量
ctrlKeyDown: false,
ctrlKeyTimer: null,
ctrlKeyLongPressThreshold: 300, // 长按阈值单位毫秒
ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
mediaCache: {}, // Add a cache to store media blobs
@@ -313,6 +347,13 @@ export default {
isToastedRunningInfo: false, // To avoid multiple toasts
activeSSECount: 0, // Track number of active SSE connections
// 流式响应开关
enableStreaming: true, // 默认开启流式响应
// 手机端相关变量
isMobile: false,
mobileMenuOpen: false,
}
},
@@ -391,6 +432,18 @@ export default {
this.sidebarCollapsed = true; // 默认折叠状态
}
// 从 localStorage 读取流式响应开关状态,默认为 true开启
const savedStreamingState = localStorage.getItem('enableStreaming');
if (savedStreamingState !== null) {
this.enableStreaming = JSON.parse(savedStreamingState);
} else {
this.enableStreaming = true; // 默认开启
}
// 检测是否为手机端
this.checkMobile();
window.addEventListener('resize', this.checkMobile);
// 设置输入框标签
this.inputFieldLabel = this.tm('input.chatPrompt');
this.getConversations();
@@ -413,6 +466,9 @@ export default {
beforeUnmount() {
// 移除keyup事件监听
document.removeEventListener('keyup', this.handleInputKeyUp);
// 移除resize事件监听
window.removeEventListener('resize', this.checkMobile);
// 清除悬停定时器
if (this.sidebarHoverTimer) {
@@ -427,6 +483,28 @@ export default {
const customizer = useCustomizerStore();
const newTheme = customizer.uiTheme === 'PurpleTheme' ? 'PurpleThemeDark' : 'PurpleTheme';
customizer.SET_UI_THEME(newTheme);
this.theme.global.name.value = newTheme;
},
// 检测是否为手机端
checkMobile() {
this.isMobile = window.innerWidth <= 768;
// 如果切换到桌面端,关闭手机菜单
if (!this.isMobile) {
this.mobileMenuOpen = false;
}
},
// 切换手机端菜单
toggleMobileSidebar() {
this.mobileMenuOpen = !this.mobileMenuOpen;
},
// 关闭手机端菜单
closeMobileSidebar() {
this.mobileMenuOpen = false;
},
// 切换流式响应
toggleStreaming() {
this.enableStreaming = !this.enableStreaming;
localStorage.setItem('enableStreaming', JSON.stringify(this.enableStreaming));
},
// 切换侧边栏折叠状态
toggleSidebar() {
@@ -441,7 +519,7 @@ export default {
// 侧边栏鼠标悬停处理
handleSidebarMouseEnter() {
if (!this.sidebarCollapsed) return;
if (!this.sidebarCollapsed || this.isMobile) return;
this.sidebarHovered = true;
@@ -668,6 +746,11 @@ export default {
return
}
// 手机端关闭侧边栏
if (this.isMobile) {
this.closeMobileSidebar();
}
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => {
this.currCid = cid[0];
// Update the selected conversation in the sidebar
@@ -748,6 +831,10 @@ export default {
this.currCid = '';
this.selectedConversations = []; // 清除选中状态
this.messages = [];
// 手机端关闭侧边栏
if (this.isMobile) {
this.closeMobileSidebar();
}
if (this.$route.path.startsWith('/chatbox')) {
this.$router.push('/chatbox');
} else {
@@ -867,7 +954,8 @@ export default {
image_url: imageNamesToSend,
audio_url: audioNameToSend ? [audioNameToSend] : [],
selected_provider: selectedProviderId,
selected_model: selectedModelName
selected_model: selectedModelName,
enable_streaming: this.enableStreaming
})
});
@@ -1101,6 +1189,17 @@ export default {
flex-direction: column;
}
/* 流式响应开关芯片样式 */
.streaming-toggle-chip {
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.streaming-toggle-chip:hover {
opacity: 0.8;
}
.welcome-title {
font-size: 28px;
margin-bottom: 16px;
@@ -1141,7 +1240,6 @@ export default {
width: 100%;
height: 100%;
max-height: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
overflow: hidden;
}
@@ -1166,7 +1264,7 @@ export default {
display: flex;
flex-direction: column;
padding: 0;
border-right: 1px solid rgba(0, 0, 0, 0.05);
border-right: 1px solid rgba(0, 0, 0, 0.04);
height: 100%;
max-height: 100%;
position: relative;
@@ -1188,6 +1286,77 @@ export default {
transition: all 0.3s ease;
}
/* 手机端菜单按钮 */
.mobile-menu-btn {
margin-right: 8px;
}
/* 手机端遮罩层 */
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease;
}
/* 手机端侧边栏 */
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
max-width: 280px !important;
min-width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 1000;
}
.mobile-sidebar-open {
transform: translateX(0) !important;
}
/* 手机端样式调整 */
@media (max-width: 768px) {
.sidebar-panel:not(.mobile-sidebar) {
display: none;
}
.chat-content-panel {
width: 100%;
}
/* 手机端去掉容器padding */
.chat-page-container {
padding: 0 !important;
}
/* 手机端输入区域样式 */
.input-area {
padding: 0 !important;
}
.input-container {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
}
#input-field {
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
}
}
/* 侧边栏折叠按钮 */
.sidebar-collapse-btn-container {
margin: 16px;
@@ -1267,25 +1436,12 @@ export default {
white-space: nowrap;
}
.status-chips {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-bottom: 8px;
transition: opacity 0.25s ease;
}
.status-chips .v-chip {
.v-chip {
flex: 1 1 0;
justify-content: center;
opacity: 0.7;
}
.status-chip {
font-size: 12px;
height: 24px !important;
}
.no-conversations {
display: flex;
flex-direction: column;

View File

@@ -33,7 +33,7 @@
<v-avatar class="bot-avatar" size="36">
<v-progress-circular :index="index" v-if="isStreaming && index === messages.length - 1" indeterminate size="28"
width="2"></v-progress-circular>
<span v-else-if="messages[index - 1]?.content.type !== 'bot'" class="text-h2"></span>
<v-icon v-else-if="messages[index - 1]?.content.type !== 'bot'" size="64" color="#8fb6d2">mdi-star-four-points-small</v-icon>
</v-avatar>
<div class="bot-message-content">
<div class="message-bubble bot-bubble">

View File

@@ -1,13 +1,10 @@
<template>
<div>
<!-- 选择提供商和模型按钮 -->
<v-btn class="text-none" variant="tonal" rounded="xl" size="small"
<v-chip class="text-none" variant="tonal" size="x-small"
v-if="selectedProviderId && selectedModelName" @click="openDialog">
{{ selectedProviderId }} / {{ selectedModelName }}
</v-btn>
<v-btn variant="tonal" rounded="xl" size="small" v-else @click="openDialog">
选择模型
</v-btn>
</v-chip>
<!-- 选择提供商和模型对话框 -->
<v-dialog v-model="showDialog" max-width="800" persistent>

View File

@@ -1,22 +1,25 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)"
style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<div v-else class="d-flex flex-wrap gap-1">
<v-chip
v-for="name in modelValue"
:key="name"
size="small"
color="primary"
variant="tonal"
closable
@click:close="removeKnowledgeBase(name)">
{{ name }}
</v-chip>
<div class="d-flex align-center justify-space-between" style="gap: 8px;">
<div style="flex: 1; min-width: 0; overflow: hidden;">
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)"
style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<div v-else class="d-flex flex-wrap gap-1">
<v-chip
v-for="name in modelValue"
:key="name"
size="small"
color="primary"
variant="tonal"
closable
@click:close="removeKnowledgeBase(name)"
style="max-width: 100%;">
<span class="text-truncate" style="max-width: 200px;">{{ name }}</span>
</v-chip>
</div>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
<v-btn size="small" color="primary" variant="tonal" @click="openDialog" style="flex-shrink: 0;">
{{ buttonText }}
</v-btn>
</div>
@@ -220,4 +223,11 @@ function goToKnowledgeBasePage() {
.gap-1 {
gap: 4px;
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
</style>

View File

@@ -70,10 +70,6 @@ const formatTitle = (title: string) => {
transition: transform 0.3s ease;
}
.logo-image img:hover {
transform: scale(1.05);
}
.logo-text {
display: flex;
flex-direction: column;

View File

@@ -2,6 +2,7 @@
"login": "Login",
"username": "Username",
"password": "Password",
"defaultHint": "Default username and password: astrbot",
"logo": {
"title": "AstrBot Dashboard",
"subtitle": "Welcome"

View File

@@ -57,6 +57,12 @@
"voiceRecord": "Record Voice",
"pasteImage": "Paste Image"
},
"streaming": {
"enabled": "Streaming enabled",
"disabled": "Streaming disabled",
"on": "Stream",
"off": "Normal"
},
"connection": {
"title": "Connection Status Notice",
"message": "The system detected that the chat connection needs to be re-established.",

View File

@@ -2,8 +2,9 @@
"login": "登录",
"username": "用户名",
"password": "密码",
"defaultHint": "默认账户和密码均为astrbot",
"logo": {
"title": "AstrBot 仪表盘",
"title": "AstrBot WebUI",
"subtitle": "欢迎使用"
},
"theme": {

View File

@@ -57,6 +57,12 @@
"voiceRecord": "录制语音",
"pasteImage": "粘贴图片"
},
"streaming": {
"enabled": "流式响应已开启",
"disabled": "流式响应已关闭",
"on": "流式",
"off": "普通"
},
"connection": {
"title": "连接状态提醒",
"message": "系统检测到聊天连接需要重新建立。",

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { ref, onMounted } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
@@ -8,6 +8,12 @@ import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import { useCustomizerStore } from '@/stores/customizer';
const customizer = useCustomizerStore();
const route = useRoute();
// 计算是否在聊天页面(非全屏模式)
const isChatPage = computed(() => {
return route.path.startsWith('/chat');
});
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
// 检查是否需要迁移
@@ -45,7 +51,10 @@ onMounted(() => {
<VerticalHeaderVue />
<VerticalSidebarVue />
<v-main>
<v-container fluid class="page-wrapper" style="height: calc(100% - 8px)">
<v-container fluid class="page-wrapper" :style="{
height: 'calc(100% - 8px)',
padding: isChatPage ? '0' : undefined
}">
<div style="height: 100%;">
<RouterView />
</div>

View File

@@ -10,6 +10,7 @@ import { useCommonStore } from '@/stores/common';
import MarkdownIt from 'markdown-it';
import { useI18n } from '@/i18n/composables';
import { router } from '@/router';
import { useTheme } from 'vuetify';
// 配置markdown-it默认安全设置
const md = new MarkdownIt({
@@ -20,6 +21,7 @@ const md = new MarkdownIt({
});
const customizer = useCustomizerStore();
const theme = useTheme();
const { t } = useI18n();
let dialog = ref(false);
let accountWarning = ref(false)
@@ -276,7 +278,9 @@ function updateDashboard() {
}
function toggleDarkMode() {
customizer.SET_UI_THEME(customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark');
const newTheme = customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark';
customizer.SET_UI_THEME(newTheme);
theme.global.name.value = newTheme;
}
getVersion();

View File

@@ -18,24 +18,38 @@ setupI18n().then(() => {
const app = createApp(App);
app.use(router);
app.use(createPinia());
const pinia = createPinia();
app.use(pinia);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
// 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
});
}).catch(error => {
console.error('❌ 新i18n系统初始化失败:', error);
// 即使i18n初始化失败也要挂载应用使用回退机制
const app = createApp(App);
app.use(router);
app.use(createPinia());
const pinia = createPinia();
app.use(pinia);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
// 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
});
});

View File

@@ -20,7 +20,7 @@ html {
.page-wrapper {
min-height: calc(100vh - 100px);
padding: 15px;
padding: 8px;
border-radius: $border-radius-root;
background: rgb(var(--v-theme-containerBg));
}

View File

@@ -10,6 +10,6 @@ import Chat from '@/components/chat/Chat.vue'
<style scoped>
.chat-container {
height: calc(100vh - 88px)
height: calc(100vh - 60px)
}
</style>

View File

@@ -1,24 +1,25 @@
<script setup lang="ts">
import AuthLogin from '../authForms/AuthLogin.vue';
import Logo from '@/components/shared/Logo.vue';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import { onMounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
import {useCustomizerStore} from "@/stores/customizer";
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
const cardVisible = ref(false);
const router = useRouter();
const authStore = useAuthStore();
const customizer = useCustomizerStore();
const { tm: t } = useModuleI18n('features/auth');
const theme = useTheme();
// 主题切换函数
function toggleTheme() {
customizer.SET_UI_THEME(
customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark'
);
const newTheme = customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark';
customizer.SET_UI_THEME(newTheme);
theme.global.name.value = newTheme;
}
onMounted(() => {
@@ -27,7 +28,7 @@ onMounted(() => {
router.push(authStore.returnUrl || '/');
return;
}
// 添加一个小延迟以获得更好的动画效果
setTimeout(() => {
cardVisible.value = true;
@@ -36,139 +37,17 @@ onMounted(() => {
</script>
<template>
<div v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="login-page-container">
<div class="login-background"></div>
<div class="login-container">
<!-- 桌面端卡片样式 -->
<v-card
v-if="!$vuetify.display.xs"
variant="outlined"
class="login-card"
:class="{ 'card-visible': cardVisible }"
>
<v-card-text class="pa-10">
<div class="logo-wrapper">
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</v-card-text>
</v-card>
<!-- 移动端全屏样式 -->
<div
v-else
class="mobile-login-container"
:class="{ 'mobile-visible': cardVisible }"
>
<div class="mobile-content">
<div class="logo-wrapper">
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</div>
</div>
<!-- 悬浮式圆角工具栏 -->
<v-card
class="floating-toolbar"
:class="{ 'toolbar-visible': cardVisible }"
elevation="8"
rounded="xl"
>
<v-card-text class="pa-2">
<div class="login-page-container">
<v-card class="login-card" elevation="1">
<v-card-title>
<div class="d-flex justify-space-between align-center w-100">
<img width="80" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<div class="d-flex align-center gap-1">
<LanguageSwitcher />
<v-divider vertical class="mx-1" style="height: 24px !important; opacity: 0.7 !important; align-self: center !important; border-color: rgba(94, 53, 177, 0.4) !important;"></v-divider>
<v-btn
@click="toggleTheme"
class="theme-toggle-btn"
icon
variant="text"
size="small"
>
<v-icon
size="18"
:color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'"
>
mdi-weather-night
</v-icon>
<v-tooltip activator="parent" location="top">
{{ t('theme.switchToDark') }}
</v-tooltip>
</v-btn>
</div>
</v-card-text>
</v-card>
</div>
</div>
<div v-else class="login-page-container-dark">
<div class="login-background-dark"></div>
<div class="login-container">
<!-- 桌面端卡片样式 -->
<v-card
v-if="!$vuetify.display.xs"
variant="outlined"
class="login-card"
:class="{ 'card-visible': cardVisible }"
>
<v-card-text class="pa-10">
<div class="logo-wrapper">
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</v-card-text>
</v-card>
<!-- 移动端全屏样式 -->
<div
v-else
class="mobile-login-container"
:class="{ 'mobile-visible': cardVisible }"
>
<div class="mobile-content">
<div class="logo-wrapper">
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</div>
</div>
<!-- 悬浮式圆角工具栏 -->
<v-card
class="floating-toolbar"
:class="{ 'toolbar-visible': cardVisible }"
elevation="8"
rounded="xl"
>
<v-card-text class="pa-2">
<div class="d-flex align-center gap-1">
<LanguageSwitcher />
<v-divider vertical class="mx-1" style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider>
<v-btn
@click="toggleTheme"
class="theme-toggle-btn"
icon
variant="text"
size="small"
>
<v-icon
size="18"
:color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'"
>
<v-divider vertical class="mx-1"
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider>
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
<v-icon size="18" :color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'">
mdi-white-balance-sunny
</v-icon>
<v-tooltip activator="parent" location="top">
@@ -176,288 +55,31 @@ onMounted(() => {
</v-tooltip>
</v-btn>
</div>
</v-card-text>
</v-card>
</div>
</div>
<div class="ml-2" style="font-size: 26px;">{{ t('logo.title') }}</div>
<div class="mt-2 ml-2" style="font-size: 14px; color: grey;">{{ t('logo.subtitle') }}</div>
</v-card-title>
<v-card-text>
<AuthLogin />
</v-card-text>
</v-card>
</div>
</template>
<style lang="scss">
.login-page-container {
background-color: rgb(var(--v-theme-containerBg));
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
position: relative;
background:
linear-gradient(-45deg,
#faf9f7 0%,
#f9f2f1 25%,
#f1f9f9 50%,
#f9f3f7 75%,
#faf9f7 100%
);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
overflow: hidden;
}
.login-page-container-dark {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
position: relative;
background:
linear-gradient(-45deg,
#1e1f21 0%,
#221e25 25%,
#1e2225 50%,
#221f23 75%,
#1e1f21 100%
);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
overflow: hidden;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.login-background {
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background: radial-gradient(circle, rgba(94, 53, 177, 0.02) 0%, rgba(94, 53, 177, 0.03) 70%);
z-index: 0;
animation: rotate 60s linear infinite;
}
.login-background-dark {
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background: radial-gradient(circle, rgba(114, 46, 209, 0.03) 0%, rgba(114, 46, 209, 0.04) 70%);
z-index: 0;
animation: rotate 60s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.login-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.floating-toolbar {
background: #f8f6fc !important;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08) !important;
backdrop-filter: blur(10px);
transform: translateY(20px);
opacity: 0;
transition: transform 0.6s ease 0.2s, opacity 0.6s ease 0.2s, border-color 0.3s ease, box-shadow 0.3s ease;
min-width: auto !important;
width: fit-content;
&.toolbar-visible {
transform: translateY(0);
opacity: 1;
}
&:hover {
transform: translateY(-2px);
border-color: rgba(158, 126, 222, 0.99) !important;
box-shadow: 0 12px 40px rgba(175, 145, 230, 0.741) !important;
}
}
.login-page-container-dark .floating-toolbar {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3) !important;
&:hover {
border-color: rgba(160, 118, 219, 0.782) !important;
box-shadow: 0 12px 40px rgba(99, 44, 175, 0.462) !important;
}
}
.theme-toggle-btn {
transition: all 0.3s ease;
border-radius: 50% !important;
min-width: 32px !important;
width: 32px !important;
height: 32px !important;
&:hover {
transform: scale(1.05);
background: rgba(94, 53, 177, 0.08) !important;
}
}
.login-page-container-dark .theme-toggle-btn:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
.login-card {
max-width: 520px;
width: 90%;
position: relative;
color: var(--v-theme-primaryText) !important;
border-radius: 16px !important;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
transform: translateY(20px);
opacity: 0;
transition: transform 0.5s ease, opacity 0.5s ease, border-color 0.3s ease, box-shadow 0.3s ease;
z-index: 1;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: calc(100% + 4px);
height: calc(100% + 4px);
transform: translate(-50%, -50%);
border-radius: 18px;
border: 2px solid rgba(94, 53, 177, 0);
transition: border-color 0.3s ease;
pointer-events: none;
z-index: -1;
}
&.card-visible {
transform: translateY(0);
opacity: 1;
}
&:hover {
border-color: rgba(158, 126, 222, 0.99) !important;
box-shadow: 0 12px 40px rgba(175, 145, 230, 0.741) !important;
transform: translateY(-2px);
&::before {
border-color: rgba(156, 114, 239, 0.907);
}
}
}
.login-page-container-dark .login-card {
border: 1px solid rgba(110, 60, 180, 0.692) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3) !important;
background: #2a2733 !important;
&::before {
border: 2px solid rgba(114, 46, 209, 0);
}
&:hover {
border-color: rgba(160, 118, 219, 0.782) !important;
box-shadow: 0 12px 40px rgba(99, 44, 175, 0.462) !important;
transform: translateY(-2px);
&::before {
border-color: rgba(114, 46, 209, 0.15);
}
}
}
.logo-wrapper {
margin-bottom: 10px;
}
.divider-container {
margin: 20px 0;
}
.custom-divider {
border-color: rgba(94, 53, 177, 0.3) !important;
opacity: 1;
}
.login-page-container-dark .custom-divider {
border-color: rgba(180, 148, 246, 0.4) !important;
}
.loginBox {
max-width: 475px;
margin: 0 auto;
}
/* 移动端全屏登录样式 */
.mobile-login-container {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
transform: translateY(20px);
opacity: 0;
transition: transform 0.5s ease, opacity 0.5s ease;
z-index: 1;
&.mobile-visible {
transform: translateY(0);
opacity: 1;
}
}
.mobile-content {
width: 100%;
max-width: 400px;
padding: 40px 20px;
}
/* 移动端调整工具栏位置 */
@media (max-width: 599px) {
.floating-toolbar {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(20px);
z-index: 1000;
&.toolbar-visible {
transform: translateX(-50%) translateY(0);
}
&:hover {
transform: translateX(-50%) translateY(-2px);
}
}
.login-container {
gap: 0;
}
width: 400px;
padding: 8px;
}
</style>

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import {ref, useCssModule} from 'vue';
import { ref, useCssModule } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { Form } from 'vee-validate';
import md5 from 'js-md5';
import {useCustomizerStore} from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
const { tm: t } = useModuleI18n('features/auth');
@@ -17,7 +16,7 @@ const loading = ref(false);
/* eslint-disable @typescript-eslint/no-explicit-any */
async function validate(values: any, { setErrors }: any) {
loading.value = true;
// md5加密
let password_ = password.value;
if (password.value != '') {
@@ -41,58 +40,25 @@ async function validate(values: any, { setErrors }: any) {
<template>
<Form @submit="validate" class="mt-4 login-form" v-slot="{ errors, isSubmitting }">
<v-text-field
v-model="username"
:label="t('username')"
class="mb-6 input-field"
required
density="comfortable"
hide-details="auto"
variant="outlined"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
prepend-inner-icon="mdi-account"
:disabled="loading"
></v-text-field>
<v-text-field
v-model="password"
:label="t('password')"
required
density="comfortable"
variant="outlined"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
hide-details="auto"
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
:type="show1 ? 'text' : 'password'"
@click:append="show1 = !show1"
class="pwd-input"
prepend-inner-icon="mdi-lock"
:disabled="loading"
></v-text-field>
<v-btn
color="secondary"
:loading="isSubmitting || loading"
block
class="login-btn mt-8"
variant="flat"
size="large"
:disabled="valid"
type="submit"
elevation="2"
<v-text-field v-model="username" :label="t('username')" class="mb-6 input-field" required hide-details="auto"
variant="outlined" prepend-inner-icon="mdi-account" :disabled="loading"></v-text-field>
>
<v-text-field v-model="password" :label="t('password')" required variant="outlined" hide-details="auto"
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'" :type="show1 ? 'text' : 'password'"
@click:append="show1 = !show1" class="pwd-input" prepend-inner-icon="mdi-lock" :disabled="loading"></v-text-field>
<div class="mt-2">
<small style="color: grey;">{{ t('defaultHint') }}</small>
</div>
<v-btn color="secondary" :loading="isSubmitting || loading" block class="login-btn mt-8" variant="flat" size="large"
:disabled="valid" type="submit">
<span class="login-btn-text">{{ t('login') }}</span>
</v-btn>
<div v-if="errors.apiError" class="mt-4 error-container">
<v-alert
color="error"
variant="tonal"
density="comfortable"
icon="mdi-alert-circle"
border="start"
>
<v-alert color="error" variant="tonal" icon="mdi-alert-circle" border="start">
{{ errors.apiError }}
</v-alert>
</div>
@@ -105,24 +71,25 @@ async function validate(values: any, { setErrors }: any) {
font-weight: 500;
}
.input-field, .pwd-input {
.input-field,
.pwd-input {
.v-field__field {
padding-top: 5px;
padding-bottom: 5px;
}
.v-field__outline {
opacity: 0.7;
}
&:hover .v-field__outline {
opacity: 0.9;
}
.v-field--focused .v-field__outline {
opacity: 1;
}
.v-field__prepend-inner {
padding-right: 8px;
opacity: 0.7;
@@ -138,36 +105,36 @@ async function validate(values: any, { setErrors }: any) {
top: 50%;
transform: translateY(-50%);
opacity: 0.7;
&:hover {
opacity: 1;
}
}
}
.login-btn {
margin-top: 12px;
height: 48px;
transition: all 0.3s ease;
letter-spacing: 0.5px;
border-radius: 8px !important;
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(94, 53, 177, 0.2) !important;
}
.login-btn-text {
font-size: 1.05rem;
font-weight: 500;
}
}
.hint-text {
color: var(--v-theme-secondaryText);
padding-left: 5px;
}
.error-container {
.v-alert {
border-left-width: 4px !important;