更新
This commit is contained in:
@@ -103,6 +103,10 @@ npm run dev
|
|||||||
|
|
||||||
登录管理后台,进入「AI 模型」,填入 API Key 和接口地址(支持 OpenAI 兼容 API)。
|
登录管理后台,进入「AI 模型」,填入 API Key 和接口地址(支持 OpenAI 兼容 API)。
|
||||||
|
|
||||||
|
### 7. 可选:接入 CosyVoice 真人感客服音色
|
||||||
|
|
||||||
|
语音对话会优先请求 CosyVoice,服务不可用时自动回落到 OpenAI 或浏览器语音。AI 播报期间会继续监听麦克风,用户插话后立即停止当前音频和剩余播放队列,并转入新一轮识别。启动 GPU 服务后,在管理后台「系统设置 → AI 客服人物」中配置人物名称、欢迎语、性格、说话人、合成模式,并可上传已授权的 WAV 音色样本和在线试听。GPU 服务部署、SFT 与零样本音色克隆说明见 [`deploy/cosyvoice.md`](deploy/cosyvoice.md)。
|
||||||
|
|
||||||
## 默认账户
|
## 默认账户
|
||||||
|
|
||||||
| 用途 | 用户名 | 密码 |
|
| 用途 | 用户名 | 密码 |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use app\model\User;
|
|||||||
use app\model\UserDailyStat;
|
use app\model\UserDailyStat;
|
||||||
use app\service\AdminScopeService;
|
use app\service\AdminScopeService;
|
||||||
use app\service\ComfyUIService;
|
use app\service\ComfyUIService;
|
||||||
|
use app\service\CosyVoiceService;
|
||||||
use app\service\DepartmentService;
|
use app\service\DepartmentService;
|
||||||
use app\service\DifyService;
|
use app\service\DifyService;
|
||||||
use app\service\OpenAIService;
|
use app\service\OpenAIService;
|
||||||
@@ -666,11 +667,87 @@ class Admin extends BaseApi
|
|||||||
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||||
$input = $this->request->put();
|
$input = $this->request->put();
|
||||||
foreach ($input as $key => $value) {
|
foreach ($input as $key => $value) {
|
||||||
|
if ($key === 'voice_persona') {
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return $this->error('AI 客服人物配置格式无效', 422);
|
||||||
|
}
|
||||||
|
$value = CosyVoiceService::normalizePersona($value);
|
||||||
|
}
|
||||||
SettingsService::set($key, $value);
|
SettingsService::set($key, $value);
|
||||||
}
|
}
|
||||||
|
CosyVoiceService::clearFailure();
|
||||||
return $this->success(null, '设置已更新');
|
return $this->success(null, '设置已更新');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function uploadVoiceReference()
|
||||||
|
{
|
||||||
|
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||||
|
$file = $this->request->file('file');
|
||||||
|
if (!$file) {
|
||||||
|
return $this->error('请选择 WAV 参考音频', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalName = basename((string) $file->getOriginalName());
|
||||||
|
$extension = strtolower($file->extension() ?: pathinfo($originalName, PATHINFO_EXTENSION));
|
||||||
|
if ($extension !== 'wav') {
|
||||||
|
return $this->error('音色样本只支持 WAV 文件', 422);
|
||||||
|
}
|
||||||
|
if ((int) $file->getSize() > 15 * 1024 * 1024) {
|
||||||
|
return $this->error('音色样本不能超过 15MB', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = @file_get_contents($file->getPathname(), false, null, 0, 12);
|
||||||
|
if (!is_string($header) || strlen($header) < 12 || substr($header, 0, 4) !== 'RIFF' || substr($header, 8, 4) !== 'WAVE') {
|
||||||
|
return $this->error('文件不是有效的 WAV 音频', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetDir = root_path() . 'storage' . DIRECTORY_SEPARATOR . 'cosyvoice';
|
||||||
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||||
|
return $this->error('无法创建音色样本目录', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedName = 'voice-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.wav';
|
||||||
|
$moved = $file->move($targetDir, $storedName);
|
||||||
|
if (!$moved) {
|
||||||
|
return $this->error('音色样本保存失败', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $targetDir . DIRECTORY_SEPARATOR . $storedName;
|
||||||
|
$persona = CosyVoiceService::getPersona();
|
||||||
|
$persona['prompt_wav'] = $path;
|
||||||
|
$persona['prompt_wav_name'] = $originalName;
|
||||||
|
$persona = CosyVoiceService::normalizePersona($persona, $persona);
|
||||||
|
SettingsService::set('voice_persona', $persona);
|
||||||
|
CosyVoiceService::clearFailure($persona);
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'name' => $persona['prompt_wav_name'],
|
||||||
|
'path' => $persona['prompt_wav'],
|
||||||
|
], '音色样本上传成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previewVoicePersona()
|
||||||
|
{
|
||||||
|
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||||
|
$text = trim((string) ($this->request->post('text') ?: '您好,我是您的 AI 客服,很高兴为您服务。'));
|
||||||
|
$text = mb_substr($text, 0, 160);
|
||||||
|
|
||||||
|
try {
|
||||||
|
CosyVoiceService::clearFailure();
|
||||||
|
$speech = CosyVoiceService::speech($text);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
return $this->error($error->getMessage(), 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($speech['audio'], 200, [
|
||||||
|
'Content-Type' => $speech['content_type'],
|
||||||
|
'Content-Length' => (string) strlen($speech['audio']),
|
||||||
|
'Cache-Control' => 'no-store',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
'X-TTS-Provider' => 'cosyvoice',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function models()
|
public function models()
|
||||||
{
|
{
|
||||||
AdminScopeService::requireAny($this->authUser(), ['menu:models', 'can_manage_models']);
|
AdminScopeService::requireAny($this->authUser(), ['menu:models', 'can_manage_models']);
|
||||||
@@ -872,6 +949,9 @@ class Admin extends BaseApi
|
|||||||
'inpaint_seed_node',
|
'inpaint_seed_node',
|
||||||
'inpaint_image_node',
|
'inpaint_image_node',
|
||||||
'inpaint_mask_node',
|
'inpaint_mask_node',
|
||||||
|
'tts_model',
|
||||||
|
'tts_voice',
|
||||||
|
'tts_instructions',
|
||||||
] as $key) {
|
] as $key) {
|
||||||
if (!array_key_exists($key, $raw)) {
|
if (!array_key_exists($key, $raw)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use app\model\UploadFile;
|
|||||||
use app\service\AgentCatalog;
|
use app\service\AgentCatalog;
|
||||||
use app\service\ComfyJobDeferredException;
|
use app\service\ComfyJobDeferredException;
|
||||||
use app\service\ComfyUIService;
|
use app\service\ComfyUIService;
|
||||||
|
use app\service\CosyVoiceService;
|
||||||
use app\service\DifyService;
|
use app\service\DifyService;
|
||||||
use app\service\DocumentTextService;
|
use app\service\DocumentTextService;
|
||||||
use app\service\OpenAIService;
|
use app\service\OpenAIService;
|
||||||
@@ -18,6 +19,133 @@ use think\facade\Log;
|
|||||||
|
|
||||||
class Chat extends BaseApi
|
class Chat extends BaseApi
|
||||||
{
|
{
|
||||||
|
public function speech()
|
||||||
|
{
|
||||||
|
$this->authUser();
|
||||||
|
$input = $this->request->post();
|
||||||
|
$text = trim((string) ($input['text'] ?? ''));
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
return $this->error('语音内容不能为空', 422);
|
||||||
|
}
|
||||||
|
if (mb_strlen($text) > 600) {
|
||||||
|
return $this->error('单次语音内容不能超过 600 个字符', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$modelId = isset($input['model_id']) && $input['model_id'] !== ''
|
||||||
|
? (int) $input['model_id']
|
||||||
|
: null;
|
||||||
|
$persona = CosyVoiceService::getPersona();
|
||||||
|
$voice = trim((string) ($persona['fallback_voice'] ?? $input['voice'] ?? 'marin'));
|
||||||
|
$allowedVoices = [
|
||||||
|
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||||
|
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||||
|
];
|
||||||
|
if (!in_array($voice, $allowedVoices, true)) {
|
||||||
|
$voice = 'marin';
|
||||||
|
}
|
||||||
|
|
||||||
|
$speech = null;
|
||||||
|
if (CosyVoiceService::canAttempt()) {
|
||||||
|
try {
|
||||||
|
$speech = CosyVoiceService::speech($text);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
Log::warning('CosyVoice speech fallback: ' . $error->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$speech) {
|
||||||
|
$model = OpenAIService::getSpeechModel($modelId);
|
||||||
|
$speech = OpenAIService::speech($model, $text, $voice);
|
||||||
|
$speech['provider'] = 'openai';
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($speech['audio'], 200, [
|
||||||
|
'Content-Type' => $speech['content_type'],
|
||||||
|
'Content-Length' => (string) strlen($speech['audio']),
|
||||||
|
'Cache-Control' => 'no-store',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
'X-TTS-Provider' => $speech['provider'] ?? 'unknown',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function speechStream(): never
|
||||||
|
{
|
||||||
|
$this->authUser();
|
||||||
|
$input = $this->request->post();
|
||||||
|
$text = trim((string) ($input['text'] ?? ''));
|
||||||
|
$requestId = trim((string) ($input['request_id'] ?? ''));
|
||||||
|
if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
|
||||||
|
$requestId = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($text === '' || mb_strlen($text) > 600 || !CosyVoiceService::canAttempt()) {
|
||||||
|
http_response_code($text === '' || mb_strlen($text) > 600 ? 422 : 503);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => $text === ''
|
||||||
|
? '语音内容不能为空'
|
||||||
|
: (mb_strlen($text) > 600 ? '单次语音内容不能超过 600 个字符' : 'CosyVoice 暂时不可用'),
|
||||||
|
'data' => null,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
@ini_set('zlib.output_compression', '0');
|
||||||
|
ignore_user_abort(false);
|
||||||
|
OpenAIService::sseHeaders();
|
||||||
|
|
||||||
|
$persona = CosyVoiceService::getPersona();
|
||||||
|
OpenAIService::sseEvent('meta', [
|
||||||
|
'provider' => 'cosyvoice',
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'cancel_url' => rtrim((string) ($persona['base_url'] ?? ''), '/')
|
||||||
|
. '/cancel/' . rawurlencode($requestId),
|
||||||
|
'format' => 'pcm_s16le',
|
||||||
|
'sample_rate' => (int) ($persona['sample_rate'] ?? 24000),
|
||||||
|
'channels' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = CosyVoiceService::streamSpeech($text, static function (string $pcm): void {
|
||||||
|
OpenAIService::sseEvent('audio', [
|
||||||
|
'audio' => base64_encode($pcm),
|
||||||
|
]);
|
||||||
|
}, $requestId);
|
||||||
|
|
||||||
|
if (empty($result['aborted'])) {
|
||||||
|
OpenAIService::sseEvent('done', [
|
||||||
|
'bytes' => (int) ($result['bytes'] ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
Log::warning('CosyVoice stream failed: ' . $error->getMessage());
|
||||||
|
OpenAIService::sseEvent('error', [
|
||||||
|
'message' => $error->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function speechCancel()
|
||||||
|
{
|
||||||
|
$this->authUser();
|
||||||
|
$requestId = trim((string) $this->request->post('request_id', ''));
|
||||||
|
if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
|
||||||
|
return $this->error('语音请求标识无效', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'cancelled' => CosyVoiceService::cancelSpeech($requestId),
|
||||||
|
'request_id' => $requestId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function completions()
|
public function completions()
|
||||||
{
|
{
|
||||||
$user = $this->authUser();
|
$user = $this->authUser();
|
||||||
@@ -29,6 +157,7 @@ class Chat extends BaseApi
|
|||||||
$attachments = $input['attachments'] ?? [];
|
$attachments = $input['attachments'] ?? [];
|
||||||
$agentId = trim((string) ($input['agent_id'] ?? ''));
|
$agentId = trim((string) ($input['agent_id'] ?? ''));
|
||||||
$imageTool = trim((string) ($input['image_tool'] ?? ''));
|
$imageTool = trim((string) ($input['image_tool'] ?? ''));
|
||||||
|
$voiceMode = !empty($input['voice_mode']);
|
||||||
$stream = ($input['stream'] ?? true) !== false;
|
$stream = ($input['stream'] ?? true) !== false;
|
||||||
|
|
||||||
$allowedImageTools = ['enhance', 'erase', 'watermark', 'cutout', 'outpaint', 'replace', 'text', 'restore', 'creative', 'commit'];
|
$allowedImageTools = ['enhance', 'erase', 'watermark', 'cutout', 'outpaint', 'replace', 'text', 'restore', 'creative', 'commit'];
|
||||||
@@ -153,7 +282,7 @@ class Chat extends BaseApi
|
|||||||
$history = Message::where('conversation_id', $conversationId)
|
$history = Message::where('conversation_id', $conversationId)
|
||||||
->field('role,content,attachments')
|
->field('role,content,attachments')
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->limit(50)
|
->limit($voiceMode ? 16 : 50)
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
$history = array_reverse($history);
|
$history = array_reverse($history);
|
||||||
@@ -167,6 +296,16 @@ class Chat extends BaseApi
|
|||||||
}
|
}
|
||||||
|
|
||||||
$apiMessages = $this->buildApiMessages($history, $model);
|
$apiMessages = $this->buildApiMessages($history, $model);
|
||||||
|
if ($voiceMode) {
|
||||||
|
$voicePersona = CosyVoiceService::getPersona();
|
||||||
|
$personaName = trim((string) ($voicePersona['name'] ?? 'AI 客服')) ?: 'AI 客服';
|
||||||
|
$personaPrompt = trim((string) ($voicePersona['role_prompt'] ?? ''));
|
||||||
|
array_unshift($apiMessages, [
|
||||||
|
'role' => 'system',
|
||||||
|
'content' => '你是名为“' . $personaName . '”的 AI 客服。人物设定:' . $personaPrompt
|
||||||
|
. ' 当前正在进行低延迟实时语音对话。像真人客服一样先回应用户的真实诉求,语气口语化、有耐心、有适度共情,不复述问题,不使用 Markdown 列表,不说“作为 AI”。先给结论,通常控制在 1 到 3 句;信息不足时每轮只追问一个最关键的问题,除非用户明确要求详细说明。',
|
||||||
|
]);
|
||||||
|
}
|
||||||
$agentImageActionAllowed = false;
|
$agentImageActionAllowed = false;
|
||||||
if ($agent) {
|
if ($agent) {
|
||||||
$agentSystemPrompt = $agent['system_prompt'];
|
$agentSystemPrompt = $agent['system_prompt'];
|
||||||
@@ -1590,6 +1729,12 @@ class Chat extends BaseApi
|
|||||||
},
|
},
|
||||||
function (string $message) use (&$streamError) {
|
function (string $message) use (&$streamError) {
|
||||||
$streamError = $message;
|
$streamError = $message;
|
||||||
|
},
|
||||||
|
function () use ($conversationId, &$resolvedExternalConversationId) {
|
||||||
|
$resolvedExternalConversationId = null;
|
||||||
|
ConversationModel::where('id', $conversationId)->update([
|
||||||
|
'external_conversation_id' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1723,6 +1868,10 @@ class Chat extends BaseApi
|
|||||||
ConversationModel::where('id', $conversationId)->update([
|
ConversationModel::where('id', $conversationId)->update([
|
||||||
'external_conversation_id' => $resolvedExternalConversationId,
|
'external_conversation_id' => $resolvedExternalConversationId,
|
||||||
]);
|
]);
|
||||||
|
} elseif (!empty($result['conversation_reset'])) {
|
||||||
|
ConversationModel::where('id', $conversationId)->update([
|
||||||
|
'external_conversation_id' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$imageAction = $agent
|
$imageAction = $agent
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ class Conversation extends BaseApi
|
|||||||
} else {
|
} else {
|
||||||
$data['model_id'] = (int) $mid;
|
$data['model_id'] = (int) $mid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$currentModelId = $conversation->model_id === null
|
||||||
|
? null
|
||||||
|
: (int) $conversation->model_id;
|
||||||
|
if ($data['model_id'] !== $currentModelId) {
|
||||||
|
// Dify conversation_id 只属于创建它的应用/模型;切换模型后不可复用。
|
||||||
|
$data['external_conversation_id'] = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($data)) {
|
if (empty($data)) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace app\controller\api;
|
|||||||
|
|
||||||
use app\model\AiModel;
|
use app\model\AiModel;
|
||||||
use app\service\AgentCatalog;
|
use app\service\AgentCatalog;
|
||||||
|
use app\service\CosyVoiceService;
|
||||||
use app\service\SettingsService;
|
use app\service\SettingsService;
|
||||||
|
|
||||||
class Settings extends BaseApi
|
class Settings extends BaseApi
|
||||||
@@ -20,6 +21,7 @@ class Settings extends BaseApi
|
|||||||
'site_name' => SettingsService::get('site_name', 'AI Chat'),
|
'site_name' => SettingsService::get('site_name', 'AI Chat'),
|
||||||
'allow_register' => $allow === true || $allow === 'true',
|
'allow_register' => $allow === true || $allow === 'true',
|
||||||
'features' => SettingsService::getFeatures(),
|
'features' => SettingsService::getFeatures(),
|
||||||
|
'voice_persona' => CosyVoiceService::publicPersona(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\service;
|
||||||
|
|
||||||
|
use think\facade\Cache;
|
||||||
|
|
||||||
|
class CosyVoiceService
|
||||||
|
{
|
||||||
|
private const MODES = ['sft', 'instruct', 'zero_shot', 'cross_lingual', 'instruct2'];
|
||||||
|
private const FALLBACK_VOICES = [
|
||||||
|
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||||
|
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function getPersona(): array
|
||||||
|
{
|
||||||
|
$defaults = [
|
||||||
|
'enabled' => (bool) config('cosyvoice.enabled', true),
|
||||||
|
'name' => '小暖',
|
||||||
|
'greeting' => '您好,我是 AI 客服小暖,请问有什么可以帮您?',
|
||||||
|
'role_prompt' => '温暖、专业、耐心,像经验丰富的真人客服一样理解用户的真实诉求。',
|
||||||
|
'base_url' => (string) config('cosyvoice.base_url', 'http://127.0.0.1:50000'),
|
||||||
|
'mode' => (string) config('cosyvoice.mode', 'sft'),
|
||||||
|
'speaker' => (string) config('cosyvoice.speaker', '中文女'),
|
||||||
|
'instruct_text' => (string) config('cosyvoice.instruct_text', ''),
|
||||||
|
'prompt_text' => (string) config('cosyvoice.prompt_text', ''),
|
||||||
|
'prompt_wav' => (string) config('cosyvoice.prompt_wav', ''),
|
||||||
|
'prompt_wav_name' => '',
|
||||||
|
'sample_rate' => (int) config('cosyvoice.sample_rate', 22050),
|
||||||
|
'fallback_voice' => 'marin',
|
||||||
|
'connect_timeout_ms' => (int) config('cosyvoice.connect_timeout_ms', 800),
|
||||||
|
'timeout_seconds' => (int) config('cosyvoice.timeout_seconds', 8),
|
||||||
|
'failure_ttl' => (int) config('cosyvoice.failure_ttl', 20),
|
||||||
|
];
|
||||||
|
$stored = SettingsService::get('voice_persona', []);
|
||||||
|
|
||||||
|
return array_merge($defaults, is_array($stored) ? $stored : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function publicPersona(): array
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
return [
|
||||||
|
'name' => $persona['name'],
|
||||||
|
'greeting' => $persona['greeting'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizePersona(array $input, ?array $current = null): array
|
||||||
|
{
|
||||||
|
$current ??= self::getPersona();
|
||||||
|
$mode = trim((string) ($input['mode'] ?? $current['mode'] ?? 'sft'));
|
||||||
|
if (!in_array($mode, self::MODES, true)) {
|
||||||
|
$mode = 'sft';
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = rtrim(trim((string) ($input['base_url'] ?? $current['base_url'] ?? '')), '/');
|
||||||
|
if ($baseUrl !== '' && !preg_match('#^https?://#i', $baseUrl)) {
|
||||||
|
$baseUrl = (string) ($current['base_url'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallbackVoice = trim((string) ($input['fallback_voice'] ?? $current['fallback_voice'] ?? 'marin'));
|
||||||
|
if (!in_array($fallbackVoice, self::FALLBACK_VOICES, true)) {
|
||||||
|
$fallbackVoice = 'marin';
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptWav = self::safePromptPath((string) ($input['prompt_wav'] ?? $current['prompt_wav'] ?? ''));
|
||||||
|
$sampleRate = (int) ($input['sample_rate'] ?? $current['sample_rate'] ?? 22050);
|
||||||
|
if (!in_array($sampleRate, [16000, 22050, 24000, 44100, 48000], true)) {
|
||||||
|
$sampleRate = 22050;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => filter_var($input['enabled'] ?? $current['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'name' => self::limitedText($input['name'] ?? $current['name'] ?? '小暖', 40),
|
||||||
|
'greeting' => self::limitedText($input['greeting'] ?? $current['greeting'] ?? '', 200),
|
||||||
|
'role_prompt' => self::limitedText($input['role_prompt'] ?? $current['role_prompt'] ?? '', 2000),
|
||||||
|
'base_url' => $baseUrl,
|
||||||
|
'mode' => $mode,
|
||||||
|
'speaker' => self::limitedText($input['speaker'] ?? $current['speaker'] ?? '中文女', 80),
|
||||||
|
'instruct_text' => self::limitedText($input['instruct_text'] ?? $current['instruct_text'] ?? '', 1000),
|
||||||
|
'prompt_text' => self::limitedText($input['prompt_text'] ?? $current['prompt_text'] ?? '', 1500),
|
||||||
|
'prompt_wav' => $promptWav,
|
||||||
|
'prompt_wav_name' => self::limitedText($input['prompt_wav_name'] ?? $current['prompt_wav_name'] ?? '', 180),
|
||||||
|
'sample_rate' => $sampleRate,
|
||||||
|
'fallback_voice' => $fallbackVoice,
|
||||||
|
'connect_timeout_ms' => max(200, min(5000, (int) ($input['connect_timeout_ms'] ?? 800))),
|
||||||
|
'timeout_seconds' => max(2, min(60, (int) ($input['timeout_seconds'] ?? 8))),
|
||||||
|
'failure_ttl' => max(5, min(300, (int) ($input['failure_ttl'] ?? 20))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isEnabled(): bool
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
return !empty($persona['enabled']) && trim((string) $persona['base_url']) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAttempt(): bool
|
||||||
|
{
|
||||||
|
if (!self::isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !Cache::get(self::failureCacheKey(self::getPersona()), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clearFailure(?array $persona = null): void
|
||||||
|
{
|
||||||
|
Cache::delete(self::failureCacheKey($persona ?? self::getPersona()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用官方 FastAPI 服务。上游返回裸 PCM16 流,这里封装为浏览器可播放的 WAV。
|
||||||
|
*
|
||||||
|
* @return array{audio: string, content_type: string, provider: string}
|
||||||
|
*/
|
||||||
|
public static function speech(string $text): array
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
if (empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||||
|
throw new \RuntimeException('CosyVoice 未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = trim((string) $persona['mode']);
|
||||||
|
if (!in_array($mode, self::MODES, true)) {
|
||||||
|
throw new \RuntimeException('CosyVoice 模式无效: ' . $mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = self::requestFields($mode, $text, $persona);
|
||||||
|
$url = rtrim((string) $persona['base_url'], '/') . '/inference_' . $mode;
|
||||||
|
$headers = ['Accept: application/octet-stream'];
|
||||||
|
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $fields,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT_MS => (int) $persona['connect_timeout_ms'],
|
||||||
|
CURLOPT_TIMEOUT => (int) $persona['timeout_seconds'],
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pcm = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$contentType = strtolower((string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: ''));
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($pcm === false) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 连接失败: ' . ($curlError ?: '网络不可达'));
|
||||||
|
}
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
$detail = self::errorDetail($pcm);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回 HTTP ' . $httpCode . ($detail ? ': ' . $detail : ''));
|
||||||
|
}
|
||||||
|
if (str_contains($contentType, 'json')) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回了错误响应: ' . (self::errorDetail($pcm) ?: '未知错误'));
|
||||||
|
}
|
||||||
|
if (strlen($pcm) < 2) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回了空音频');
|
||||||
|
}
|
||||||
|
|
||||||
|
// PCM16 每个采样占两个字节;丢弃异常的尾部半个采样。
|
||||||
|
if (strlen($pcm) % 2 !== 0) {
|
||||||
|
$pcm = substr($pcm, 0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::delete(self::failureCacheKey($persona));
|
||||||
|
return [
|
||||||
|
'audio' => self::pcm16ToWav($pcm, (int) $persona['sample_rate']),
|
||||||
|
'content_type' => 'audio/wav',
|
||||||
|
'provider' => 'cosyvoice',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 CosyVoice 上游产生的 PCM16 数据块原样向下游推送,避免等待整段音频生成完成。
|
||||||
|
*
|
||||||
|
* @return array{bytes: int, sample_rate: int, provider: string, aborted: bool}
|
||||||
|
*/
|
||||||
|
public static function streamSpeech(string $text, callable $onChunk, string $requestId = ''): array
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
if (empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||||
|
throw new \RuntimeException('CosyVoice 未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = trim((string) $persona['mode']);
|
||||||
|
if (!in_array($mode, self::MODES, true)) {
|
||||||
|
throw new \RuntimeException('CosyVoice 模式无效: ' . $mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = self::requestFields($mode, $text, $persona);
|
||||||
|
if ($requestId !== '') {
|
||||||
|
$fields['request_id'] = $requestId;
|
||||||
|
}
|
||||||
|
$url = rtrim((string) $persona['base_url'], '/') . '/inference_' . $mode;
|
||||||
|
$headers = ['Accept: application/octet-stream'];
|
||||||
|
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$httpCode = 0;
|
||||||
|
$contentType = '';
|
||||||
|
$errorBody = '';
|
||||||
|
$bytes = 0;
|
||||||
|
$aborted = false;
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $fields,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => false,
|
||||||
|
CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$httpCode, &$contentType): int {
|
||||||
|
if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+(\d+)/i', trim($header), $matches)) {
|
||||||
|
$httpCode = (int) $matches[1];
|
||||||
|
} elseif (stripos($header, 'Content-Type:') === 0) {
|
||||||
|
$contentType = strtolower(trim(substr($header, strlen('Content-Type:'))));
|
||||||
|
}
|
||||||
|
return strlen($header);
|
||||||
|
},
|
||||||
|
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (
|
||||||
|
&$httpCode,
|
||||||
|
&$contentType,
|
||||||
|
&$errorBody,
|
||||||
|
&$bytes,
|
||||||
|
&$aborted,
|
||||||
|
$onChunk
|
||||||
|
): int {
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300 || str_contains($contentType, 'json')) {
|
||||||
|
$errorBody .= $chunk;
|
||||||
|
return strlen($chunk);
|
||||||
|
}
|
||||||
|
if (connection_aborted()) {
|
||||||
|
$aborted = true;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes += strlen($chunk);
|
||||||
|
$onChunk($chunk);
|
||||||
|
return strlen($chunk);
|
||||||
|
},
|
||||||
|
CURLOPT_CONNECTTIMEOUT_MS => (int) $persona['connect_timeout_ms'],
|
||||||
|
CURLOPT_TIMEOUT => (int) $persona['timeout_seconds'],
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = curl_exec($ch);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
$curlErrno = curl_errno($ch);
|
||||||
|
if ($httpCode === 0) {
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($aborted || ($result === false && $curlErrno === CURLE_WRITE_ERROR && connection_aborted())) {
|
||||||
|
return [
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'sample_rate' => (int) $persona['sample_rate'],
|
||||||
|
'provider' => 'cosyvoice',
|
||||||
|
'aborted' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($result === false) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 流连接失败: ' . ($curlError ?: '网络不可达'));
|
||||||
|
}
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
$detail = self::errorDetail($errorBody);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回 HTTP ' . $httpCode . ($detail ? ': ' . $detail : ''));
|
||||||
|
}
|
||||||
|
if (str_contains($contentType, 'json')) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回了错误响应: ' . (self::errorDetail($errorBody) ?: '未知错误'));
|
||||||
|
}
|
||||||
|
if ($bytes < 2) {
|
||||||
|
self::rememberFailure($persona);
|
||||||
|
throw new \RuntimeException('CosyVoice 返回了空音频');
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::delete(self::failureCacheKey($persona));
|
||||||
|
return [
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'sample_rate' => (int) $persona['sample_rate'],
|
||||||
|
'provider' => 'cosyvoice',
|
||||||
|
'aborted' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function cancelSpeech(string $requestId): bool
|
||||||
|
{
|
||||||
|
$persona = self::getPersona();
|
||||||
|
$requestId = trim($requestId);
|
||||||
|
if ($requestId === '' || empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = rtrim((string) $persona['base_url'], '/') . '/cancel/' . rawurlencode($requestId);
|
||||||
|
$headers = ['Accept: application/json'];
|
||||||
|
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => '',
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT_MS => min(800, (int) $persona['connect_timeout_ms']),
|
||||||
|
CURLOPT_TIMEOUT_MS => 1500,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($body === false || $httpCode < 200 || $httpCode >= 300) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($body, true);
|
||||||
|
return is_array($payload) && !empty($payload['cancelled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function requestFields(string $mode, string $text, array $persona): array
|
||||||
|
{
|
||||||
|
$fields = ['tts_text' => $text];
|
||||||
|
$speaker = trim((string) $persona['speaker']);
|
||||||
|
$instruct = trim((string) $persona['instruct_text']);
|
||||||
|
|
||||||
|
if (in_array($mode, ['sft', 'instruct'], true)) {
|
||||||
|
$fields['spk_id'] = $speaker;
|
||||||
|
}
|
||||||
|
if (in_array($mode, ['instruct', 'instruct2'], true)) {
|
||||||
|
$fields['instruct_text'] = $instruct;
|
||||||
|
}
|
||||||
|
if (in_array($mode, ['zero_shot', 'instruct2'], true)) {
|
||||||
|
$fields['prompt_text'] = trim((string) $persona['prompt_text']);
|
||||||
|
}
|
||||||
|
if (in_array($mode, ['zero_shot', 'cross_lingual', 'instruct2'], true)) {
|
||||||
|
$promptWav = trim((string) $persona['prompt_wav']);
|
||||||
|
if ($promptWav === '' || !is_file($promptWav)) {
|
||||||
|
throw new \RuntimeException('CosyVoice 音色样本不存在: ' . $promptWav);
|
||||||
|
}
|
||||||
|
$fields['prompt_wav'] = new \CURLFile($promptWav, 'audio/wav', basename($promptWav));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function pcm16ToWav(string $pcm, int $sampleRate): string
|
||||||
|
{
|
||||||
|
$channels = 1;
|
||||||
|
$bitsPerSample = 16;
|
||||||
|
$dataSize = strlen($pcm);
|
||||||
|
$blockAlign = (int) ($channels * $bitsPerSample / 8);
|
||||||
|
$byteRate = $sampleRate * $blockAlign;
|
||||||
|
|
||||||
|
return 'RIFF'
|
||||||
|
. pack('V', 36 + $dataSize)
|
||||||
|
. 'WAVEfmt '
|
||||||
|
. pack('VvvVVvv', 16, 1, $channels, $sampleRate, $byteRate, $blockAlign, $bitsPerSample)
|
||||||
|
. 'data'
|
||||||
|
. pack('V', $dataSize)
|
||||||
|
. $pcm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function errorDetail(string $body): string
|
||||||
|
{
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
if (is_array($data)) {
|
||||||
|
$detail = $data['detail'] ?? $data['message'] ?? $data['error'] ?? '';
|
||||||
|
if (is_array($detail)) {
|
||||||
|
return json_encode($detail, JSON_UNESCAPED_UNICODE) ?: '';
|
||||||
|
}
|
||||||
|
return trim((string) $detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_substr(trim(strip_tags($body)), 0, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rememberFailure(array $persona): void
|
||||||
|
{
|
||||||
|
Cache::set(
|
||||||
|
self::failureCacheKey($persona),
|
||||||
|
true,
|
||||||
|
(int) $persona['failure_ttl']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function failureCacheKey(array $persona): string
|
||||||
|
{
|
||||||
|
return 'cosyvoice_unavailable_' . md5((string) $persona['base_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function limitedText(mixed $value, int $maxLength): string
|
||||||
|
{
|
||||||
|
return mb_substr(trim((string) $value), 0, $maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function safePromptPath(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim($path);
|
||||||
|
if ($path === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$realPath = realpath($path);
|
||||||
|
$allowedRoot = realpath(root_path() . 'storage' . DIRECTORY_SEPARATOR . 'cosyvoice');
|
||||||
|
if (!$realPath || !$allowedRoot || !is_file($realPath)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedPath = strtolower(str_replace('\\', '/', $realPath));
|
||||||
|
$normalizedRoot = rtrim(strtolower(str_replace('\\', '/', $allowedRoot)), '/') . '/';
|
||||||
|
return str_starts_with($normalizedPath, $normalizedRoot) ? $realPath : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,61 +21,147 @@ class DifyService
|
|||||||
public static function chat(AiModel $model, string $query, array $files, ?string $conversationId, string $userId): array
|
public static function chat(AiModel $model, string $query, array $files, ?string $conversationId, string $userId): array
|
||||||
{
|
{
|
||||||
$url = rtrim($model->api_base_url, '/') . '/chat-messages';
|
$url = rtrim($model->api_base_url, '/') . '/chat-messages';
|
||||||
$payload = self::buildPayload($query, $files, $conversationId, $userId, false);
|
$conversationReset = false;
|
||||||
|
|
||||||
$ch = curl_init($url);
|
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||||
curl_setopt_array($ch, [
|
$requestConversationId = $attempt === 0 ? $conversationId : null;
|
||||||
CURLOPT_POST => true,
|
$payload = self::buildPayload($query, $files, $requestConversationId, $userId, false);
|
||||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
|
||||||
CURLOPT_HTTPHEADER => [
|
|
||||||
'Content-Type: application/json',
|
|
||||||
'Authorization: Bearer ' . $model->api_key,
|
|
||||||
],
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_TIMEOUT => 120,
|
|
||||||
CURLOPT_CONNECTTIMEOUT => 15,
|
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$ch = curl_init($url);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
curl_setopt_array($ch, [
|
||||||
$curlError = curl_error($ch);
|
CURLOPT_POST => true,
|
||||||
curl_close($ch);
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Authorization: Bearer ' . $model->api_key,
|
||||||
|
],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 15,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($response === false) {
|
$response = curl_exec($ch);
|
||||||
throw new HttpResponseException(json([
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
'code' => 1,
|
$curlError = curl_error($ch);
|
||||||
'message' => 'Dify 请求失败: ' . ($curlError ?: '网络错误'),
|
curl_close($ch);
|
||||||
'data' => null,
|
|
||||||
], 502));
|
if ($response === false) {
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => 'Dify 请求失败: ' . ($curlError ?: '网络错误'),
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
||||||
|
if (
|
||||||
|
$attempt === 0 &&
|
||||||
|
$conversationId &&
|
||||||
|
self::isConversationNotFoundError($response . ' ' . $detail)
|
||||||
|
) {
|
||||||
|
$conversationReset = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => self::humanizeError('Dify 请求失败: ' . $detail . self::urlHint($httpCode)),
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (!$data) {
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => 'Dify 响应解析失败',
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'answer' => $data['answer'] ?? '',
|
||||||
|
'conversation_id' => $data['conversation_id'] ?? null,
|
||||||
|
'conversation_reset' => $conversationReset,
|
||||||
|
'tokens' => $data['metadata']['usage']['total_tokens'] ?? 0,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($httpCode !== 200) {
|
throw new \RuntimeException('Dify 会话恢复失败');
|
||||||
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
|
||||||
throw new HttpResponseException(json([
|
|
||||||
'code' => 1,
|
|
||||||
'message' => self::humanizeError('Dify 请求失败: ' . $detail . self::urlHint($httpCode)),
|
|
||||||
'data' => null,
|
|
||||||
], 502));
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($response, true);
|
|
||||||
if (!$data) {
|
|
||||||
throw new HttpResponseException(json([
|
|
||||||
'code' => 1,
|
|
||||||
'message' => 'Dify 响应解析失败',
|
|
||||||
'data' => null,
|
|
||||||
], 502));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'answer' => $data['answer'] ?? '',
|
|
||||||
'conversation_id' => $data['conversation_id'] ?? null,
|
|
||||||
'tokens' => $data['metadata']['usage']['total_tokens'] ?? 0,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function streamChat(
|
public static function streamChat(
|
||||||
|
AiModel $model,
|
||||||
|
string $query,
|
||||||
|
array $files,
|
||||||
|
?string $conversationId,
|
||||||
|
string $userId,
|
||||||
|
callable $onChunk,
|
||||||
|
callable $onDone,
|
||||||
|
?callable $onError = null,
|
||||||
|
?callable $onConversationReset = null
|
||||||
|
): void {
|
||||||
|
$emittedContent = false;
|
||||||
|
$attemptError = null;
|
||||||
|
$attemptDone = null;
|
||||||
|
|
||||||
|
$chunkProxy = function (string $delta) use (&$emittedContent, $onChunk) {
|
||||||
|
$emittedContent = true;
|
||||||
|
$onChunk($delta);
|
||||||
|
};
|
||||||
|
$doneProxy = function (?string $newConversationId, int $tokens) use (&$attemptDone) {
|
||||||
|
$attemptDone = [$newConversationId, $tokens];
|
||||||
|
};
|
||||||
|
$errorProxy = function (string $message) use (&$attemptError) {
|
||||||
|
$attemptError = $message;
|
||||||
|
};
|
||||||
|
|
||||||
|
self::streamChatAttempt(
|
||||||
|
$model,
|
||||||
|
$query,
|
||||||
|
$files,
|
||||||
|
$conversationId,
|
||||||
|
$userId,
|
||||||
|
$chunkProxy,
|
||||||
|
$doneProxy,
|
||||||
|
$errorProxy
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$attemptDone === null &&
|
||||||
|
!$emittedContent &&
|
||||||
|
$conversationId &&
|
||||||
|
self::isConversationNotFoundError((string) $attemptError)
|
||||||
|
) {
|
||||||
|
if ($onConversationReset) {
|
||||||
|
$onConversationReset();
|
||||||
|
}
|
||||||
|
$attemptError = null;
|
||||||
|
self::streamChatAttempt(
|
||||||
|
$model,
|
||||||
|
$query,
|
||||||
|
$files,
|
||||||
|
null,
|
||||||
|
$userId,
|
||||||
|
$chunkProxy,
|
||||||
|
$doneProxy,
|
||||||
|
$errorProxy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attemptDone !== null) {
|
||||||
|
$onDone($attemptDone[0], $attemptDone[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($onError && $attemptError !== null) {
|
||||||
|
$onError($attemptError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function streamChatAttempt(
|
||||||
AiModel $model,
|
AiModel $model,
|
||||||
string $query,
|
string $query,
|
||||||
array $files,
|
array $files,
|
||||||
@@ -373,6 +459,25 @@ class DifyService
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function isConversationNotFoundError(string $message): bool
|
||||||
|
{
|
||||||
|
$message = mb_strtolower($message);
|
||||||
|
foreach ([
|
||||||
|
'conversation not exists',
|
||||||
|
'conversation does not exist',
|
||||||
|
'conversation not found',
|
||||||
|
'conversation_not_exists',
|
||||||
|
'conversation_not_found',
|
||||||
|
'dify 会话已失效',
|
||||||
|
] as $needle) {
|
||||||
|
if (str_contains($message, $needle)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static function parseErrorBody(?string $body): ?string
|
private static function parseErrorBody(?string $body): ?string
|
||||||
{
|
{
|
||||||
if (!$body) {
|
if (!$body) {
|
||||||
@@ -390,6 +495,10 @@ class DifyService
|
|||||||
*/
|
*/
|
||||||
public static function humanizeError(string $message): string
|
public static function humanizeError(string $message): string
|
||||||
{
|
{
|
||||||
|
if (self::isConversationNotFoundError($message)) {
|
||||||
|
return 'Dify 会话已失效,系统创建新会话后仍未恢复,请稍后重新发送。';
|
||||||
|
}
|
||||||
|
|
||||||
if (str_contains($message, "Unsupported chat content part type: 'file'")
|
if (str_contains($message, "Unsupported chat content part type: 'file'")
|
||||||
|| str_contains($message, 'Unsupported chat content part type')) {
|
|| str_contains($message, 'Unsupported chat content part type')) {
|
||||||
return 'Dify 模型层仍不接受 file 类型。请确认 Dify 应用已开启文档上传,且 files.type 使用 document(不是 file)。'
|
return 'Dify 模型层仍不接受 file 类型。请确认 Dify 应用已开启文档上传,且 files.type 使用 document(不是 file)。'
|
||||||
|
|||||||
@@ -55,6 +55,35 @@ class OpenAIService
|
|||||||
return $model;
|
return $model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音合成必须使用 OpenAI 兼容协议模型。Dify/ComfyUI 的 API 地址不提供
|
||||||
|
* /audio/speech,因此所选对话模型不兼容时自动回落到已启用的 OpenAI 模型。
|
||||||
|
*/
|
||||||
|
public static function getSpeechModel(?int $preferredModelId = null): AiModel
|
||||||
|
{
|
||||||
|
$model = null;
|
||||||
|
if ($preferredModelId) {
|
||||||
|
$model = AiModel::where('id', $preferredModelId)
|
||||||
|
->where('enabled', 1)
|
||||||
|
->where('provider', 'openai')
|
||||||
|
->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$model) {
|
||||||
|
$model = AiModel::where('enabled', 1)
|
||||||
|
->where('provider', 'openai')
|
||||||
|
->order('is_default', 'desc')
|
||||||
|
->order('sort_order')
|
||||||
|
->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$model) {
|
||||||
|
self::throwUnavailableModel('未配置支持语音合成的 OpenAI 兼容模型');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $model;
|
||||||
|
}
|
||||||
|
|
||||||
public static function getImageModel(?int $preferredModelId = null): AiModel
|
public static function getImageModel(?int $preferredModelId = null): AiModel
|
||||||
{
|
{
|
||||||
$model = null;
|
$model = null;
|
||||||
@@ -252,6 +281,106 @@ class OpenAIService
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用神经语音模型生成短句 WAV。短句由前端在文本流式输出期间提前提交,
|
||||||
|
* WAV 则避免浏览器额外的解码启动开销。
|
||||||
|
*
|
||||||
|
* @return array{audio: string, content_type: string}
|
||||||
|
*/
|
||||||
|
public static function speech(AiModel $model, string $input, string $voice = 'marin'): array
|
||||||
|
{
|
||||||
|
$extra = is_array($model->extra_config ?? null) ? $model->extra_config : [];
|
||||||
|
$ttsModel = trim((string) ($extra['tts_model'] ?? 'gpt-4o-mini-tts'));
|
||||||
|
$ttsVoice = trim((string) ($extra['tts_voice'] ?? $voice));
|
||||||
|
$instructions = trim((string) ($extra['tts_instructions'] ?? (
|
||||||
|
'Speak in natural, warm, conversational Mandarin Chinese. '
|
||||||
|
. 'Use relaxed pacing, subtle emotion, human-like phrasing and short natural pauses. '
|
||||||
|
. 'Avoid an announcer, customer-service, robotic, or overly enthusiastic tone.'
|
||||||
|
)));
|
||||||
|
|
||||||
|
$allowedVoices = [
|
||||||
|
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||||
|
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||||
|
];
|
||||||
|
if (!in_array($ttsVoice, $allowedVoices, true)) {
|
||||||
|
$ttsVoice = 'marin';
|
||||||
|
}
|
||||||
|
if ($ttsModel === '') {
|
||||||
|
$ttsModel = 'gpt-4o-mini-tts';
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyTts = in_array($ttsModel, ['tts-1', 'tts-1-hd'], true);
|
||||||
|
if ($legacyTts && !in_array($ttsVoice, ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], true)) {
|
||||||
|
$ttsVoice = 'nova';
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = rtrim((string) $model->api_base_url, '/') . '/audio/speech';
|
||||||
|
$payload = [
|
||||||
|
'model' => $ttsModel,
|
||||||
|
'input' => $input,
|
||||||
|
'voice' => $ttsVoice,
|
||||||
|
'response_format' => 'wav',
|
||||||
|
];
|
||||||
|
if (!$legacyTts && $instructions !== '') {
|
||||||
|
$payload['instructions'] = $instructions;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: audio/wav',
|
||||||
|
'Authorization: Bearer ' . $model->api_key,
|
||||||
|
],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 10,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$contentType = (string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'audio/wav');
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => '语音合成连接失败: ' . ($curlError ?: '网络不可达'),
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => '自然语音生成失败: ' . $detail,
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response === '') {
|
||||||
|
throw new HttpResponseException(json([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => '语音服务返回了空音频',
|
||||||
|
'data' => null,
|
||||||
|
], 502));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!str_starts_with(strtolower($contentType), 'audio/')) {
|
||||||
|
$contentType = 'audio/wav';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'audio' => $response,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试模型连接是否正常
|
* 测试模型连接是否正常
|
||||||
* @return array{success: bool, latency_ms: int, reply: string, model: string}
|
* @return array{success: bool, latency_ms: int, reply: string, model: string}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// 开启后优先请求 CosyVoice;服务异常时会自动回落到 OpenAI / 浏览器语音。
|
||||||
|
'enabled' => filter_var(env('COSYVOICE_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'base_url' => rtrim((string) env('COSYVOICE_BASE_URL', 'http://127.0.0.1:50000'), '/'),
|
||||||
|
// sft / instruct / zero_shot / cross_lingual / instruct2
|
||||||
|
'mode' => (string) env('COSYVOICE_MODE', 'sft'),
|
||||||
|
'speaker' => (string) env('COSYVOICE_SPEAKER', '中文女'),
|
||||||
|
'instruct_text' => (string) env(
|
||||||
|
'COSYVOICE_INSTRUCT',
|
||||||
|
'请用温暖、自然、耐心的中文客服语气表达,语速适中,停顿真实,避免播音腔和夸张情绪。'
|
||||||
|
),
|
||||||
|
'prompt_text' => (string) env('COSYVOICE_PROMPT_TEXT', ''),
|
||||||
|
'prompt_wav' => (string) env(
|
||||||
|
'COSYVOICE_PROMPT_WAV',
|
||||||
|
root_path() . 'storage/cosyvoice/customer-service.wav'
|
||||||
|
),
|
||||||
|
// CosyVoice-300M 常用 22050;CosyVoice2/3 通常应配置为 24000。
|
||||||
|
'sample_rate' => max(8000, (int) env('COSYVOICE_SAMPLE_RATE', 22050)),
|
||||||
|
'connect_timeout_ms' => max(200, (int) env('COSYVOICE_CONNECT_TIMEOUT_MS', 800)),
|
||||||
|
'timeout_seconds' => max(2, (int) env('COSYVOICE_TIMEOUT_SECONDS', 8)),
|
||||||
|
'failure_ttl' => max(5, (int) env('COSYVOICE_FAILURE_TTL', 20)),
|
||||||
|
// 官方 FastAPI 默认无鉴权;经网关暴露时可使用 Bearer Token。
|
||||||
|
'api_key' => (string) env('COSYVOICE_API_KEY', ''),
|
||||||
|
];
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.conv-page[data-v-f8276717]{display:flex;flex-direction:column;height:calc(100vh - 48px);min-height:560px}.page-header[data-v-f8276717]{flex-shrink:0;margin-bottom:16px}.conv-layout[data-v-f8276717]{flex:1;min-height:0;display:grid;grid-template-columns:320px 1fr;gap:16px}.panel[data-v-f8276717]{display:flex;flex-direction:column;min-height:0;padding:0;overflow:hidden}.list-toolbar[data-v-f8276717]{padding:16px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:8px}.dept-filter[data-v-f8276717],.search-input[data-v-f8276717]{font-size:13px}.conv-list[data-v-f8276717]{flex:1;overflow-y:auto;padding:8px}.conv-item[data-v-f8276717]{width:100%;text-align:left;padding:12px;border-radius:8px;margin-bottom:4px;transition:background .15s}.conv-item[data-v-f8276717]:hover{background:#ffffff0a}.conv-item.active[data-v-f8276717]{background:#6366f126;border:1px solid rgba(99,102,241,.35)}.conv-item-title[data-v-f8276717]{font-size:14px;font-weight:500;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.conv-item-meta[data-v-f8276717],.conv-item-time[data-v-f8276717]{font-size:12px;color:var(--text-muted)}.conv-item-meta[data-v-f8276717]{display:flex;justify-content:space-between;gap:8px;margin-bottom:2px}.list-pagination[data-v-f8276717]{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-top:1px solid var(--border);font-size:13px;color:var(--text-secondary)}.detail-header[data-v-f8276717]{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:16px 20px;border-bottom:1px solid var(--border)}.detail-header h3[data-v-f8276717]{font-size:16px;margin-bottom:4px}.detail-meta[data-v-f8276717]{font-size:13px;color:var(--text-secondary);display:flex;flex-wrap:wrap;gap:4px}.messages-scroll[data-v-f8276717]{flex:1;overflow-y:auto;padding:20px}.messages[data-v-f8276717]{display:flex;flex-direction:column;gap:16px}.message[data-v-f8276717]{display:flex;gap:10px;max-width:85%}.message.user[data-v-f8276717]{flex-direction:row-reverse;align-self:flex-end}.message.assistant[data-v-f8276717]{align-self:flex-start}.message-avatar[data-v-f8276717]{flex-shrink:0;width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;background:var(--bg-tertiary);color:var(--text-secondary)}.message.user .message-avatar[data-v-f8276717]{background:var(--accent);color:#fff}.message-body[data-v-f8276717]{min-width:0}.message-content[data-v-f8276717]{padding:10px 14px;border-radius:12px;background:var(--bg-tertiary);font-size:14px;line-height:1.6;white-space:pre-wrap;word-break:break-word}.message.user .message-content[data-v-f8276717]{background:#6366f133;border:1px solid rgba(99,102,241,.3)}.message-time[data-v-f8276717]{display:block;margin-top:4px;font-size:11px;color:var(--text-muted)}.message.user .message-time[data-v-f8276717]{text-align:right}.attachments[data-v-f8276717]{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}.att-image[data-v-f8276717]{max-width:240px;max-height:180px;border-radius:8px;cursor:pointer;border:1px solid var(--border)}.att-link[data-v-f8276717]{display:inline-flex;align-items:center;gap:6px;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;font-size:13px;color:var(--text-primary)}.att-link[data-v-f8276717]:hover{border-color:var(--accent)}.detail-empty[data-v-f8276717],.empty[data-v-f8276717]{text-align:center;padding:48px 24px;color:var(--text-muted);font-size:14px}.detail-empty[data-v-f8276717]{flex:1;display:flex;align-items:center;justify-content:center}@media(max-width:900px){.conv-layout[data-v-f8276717]{grid-template-columns:1fr;grid-template-rows:280px 1fr}.conv-page[data-v-f8276717]{height:auto;min-height:calc(100vh - 48px)}}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.conv-page[data-v-f98449d7]{display:flex;flex-direction:column;height:calc(100vh - 48px);min-height:560px}.page-header[data-v-f98449d7]{flex-shrink:0;margin-bottom:16px}.conv-layout[data-v-f98449d7]{flex:1;min-height:0;display:grid;grid-template-columns:320px 1fr;gap:16px}.panel[data-v-f98449d7]{display:flex;flex-direction:column;min-height:0;padding:0;overflow:hidden}.list-toolbar[data-v-f98449d7]{padding:16px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:8px}.dept-filter[data-v-f98449d7],.search-input[data-v-f98449d7]{font-size:13px}.conv-list[data-v-f98449d7]{flex:1;overflow-y:auto;padding:8px}.conv-item[data-v-f98449d7]{width:100%;text-align:left;padding:12px;border-radius:8px;margin-bottom:4px;transition:background .15s}.conv-item[data-v-f98449d7]:hover{background:#ffffff0a}.conv-item.active[data-v-f98449d7]{background:var(--accent-soft);border:1px solid rgba(183,243,107,.28)}.conv-item-title[data-v-f98449d7]{font-size:14px;font-weight:500;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.conv-item-meta[data-v-f98449d7],.conv-item-time[data-v-f98449d7]{font-size:12px;color:var(--text-muted)}.conv-item-meta[data-v-f98449d7]{display:flex;justify-content:space-between;gap:8px;margin-bottom:2px}.list-pagination[data-v-f98449d7]{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-top:1px solid var(--border);font-size:13px;color:var(--text-secondary)}.detail-header[data-v-f98449d7]{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:16px 20px;border-bottom:1px solid var(--border)}.detail-header h3[data-v-f98449d7]{font-size:16px;margin-bottom:4px}.detail-meta[data-v-f98449d7]{font-size:13px;color:var(--text-secondary);display:flex;flex-wrap:wrap;gap:4px}.messages-scroll[data-v-f98449d7]{flex:1;overflow-y:auto;padding:20px}.messages[data-v-f98449d7]{display:flex;flex-direction:column;gap:16px}.message[data-v-f98449d7]{display:flex;gap:10px;max-width:85%}.message.user[data-v-f98449d7]{flex-direction:row-reverse;align-self:flex-end}.message.assistant[data-v-f98449d7]{align-self:flex-start}.message-avatar[data-v-f98449d7]{flex-shrink:0;width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;background:var(--bg-tertiary);color:var(--text-secondary)}.message.user .message-avatar[data-v-f98449d7]{background:var(--accent);color:#fff}.message-body[data-v-f98449d7]{min-width:0}.message-content[data-v-f98449d7]{padding:10px 14px;border-radius:12px;background:var(--bg-tertiary);font-size:14px;line-height:1.6;white-space:pre-wrap;word-break:break-word}.message.user .message-content[data-v-f98449d7]{background:var(--accent-soft);border:1px solid rgba(183,243,107,.24)}.message-time[data-v-f98449d7]{display:block;margin-top:4px;font-size:11px;color:var(--text-muted)}.message.user .message-time[data-v-f98449d7]{text-align:right}.attachments[data-v-f98449d7]{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}.att-image[data-v-f98449d7]{max-width:240px;max-height:180px;border-radius:8px;cursor:pointer;border:1px solid var(--border)}.att-link[data-v-f98449d7]{display:inline-flex;align-items:center;gap:6px;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;font-size:13px;color:var(--text-primary)}.att-link[data-v-f98449d7]:hover{border-color:var(--accent)}.detail-empty[data-v-f98449d7],.empty[data-v-f98449d7]{text-align:center;padding:48px 24px;color:var(--text-muted);font-size:14px}.detail-empty[data-v-f98449d7]{flex:1;display:flex;align-items:center;justify-content:center}@media(max-width:900px){.conv-layout[data-v-f98449d7]{grid-template-columns:1fr;grid-template-rows:280px 1fr}.conv-page[data-v-f98449d7]{height:auto;min-height:calc(100vh - 48px)}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{_ as l,g as e,h as d,c as i,a as s,t as o,r,o as p}from"./index-z4tF8s-R.js";const u={class:"stats-grid"},v={class:"stat-card"},c={class:"stat-value"},_={class:"stat-card"},m={class:"stat-value"},g={class:"stat-card"},b={class:"stat-value"},f={class:"stat-card"},y={class:"stat-value"},w={__name:"DashboardView",setup(x){const t=r({users:0,conversations:0,messages:0,today_messages:0});return e(async()=>{const n=await d.get("/admin/stats");t.value=n.data.data}),(n,a)=>(p(),i("div",null,[a[8]||(a[8]=s("div",{class:"page-header"},[s("h2",null,"数据概览"),s("p",null,"系统运行统计数据")],-1)),s("div",u,[s("div",v,[a[0]||(a[0]=s("span",{class:"stat-icon"},"👥",-1)),s("span",c,o(t.value.users),1),a[1]||(a[1]=s("span",{class:"stat-label"},"用户总数",-1))]),s("div",_,[a[2]||(a[2]=s("span",{class:"stat-icon"},"💬",-1)),s("span",m,o(t.value.conversations),1),a[3]||(a[3]=s("span",{class:"stat-label"},"会话总数",-1))]),s("div",g,[a[4]||(a[4]=s("span",{class:"stat-icon"},"📝",-1)),s("span",b,o(t.value.messages),1),a[5]||(a[5]=s("span",{class:"stat-label"},"消息总数",-1))]),s("div",f,[a[6]||(a[6]=s("span",{class:"stat-icon"},"📈",-1)),s("span",y,o(t.value.today_messages),1),a[7]||(a[7]=s("span",{class:"stat-label"},"今日消息",-1))])])]))}},D=l(w,[["__scopeId","data-v-5214a2c8"]]);export{D as default};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.stats-grid[data-v-5a83d2b7]{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:14px}.stat-card[data-v-5a83d2b7]{display:grid;min-height:178px;grid-template-columns:1fr auto;grid-template-rows:auto 1fr auto;padding:19px;transition:transform .22s var(--ease-spring),border-color .18s ease,box-shadow .18s ease}.stat-card[data-v-5a83d2b7]:hover{border-color:#b7f36b38;box-shadow:inset 0 1px #ffffff0b,0 22px 52px #00000057;transform:translateY(-3px)}.stat-icon[data-v-5a83d2b7]{z-index:1;display:grid;width:42px;height:42px;place-items:center;border:1px solid rgba(183,243,107,.24);border-radius:12px;background:var(--accent-soft);color:var(--accent);box-shadow:inset 0 1px #ffffff0e,0 0 22px #b7f36b0f}.stat-value[data-v-5a83d2b7]{z-index:1;align-self:end;color:var(--text-primary);font-family:Cascadia Code,Consolas,monospace;font-size:clamp(34px,4vw,48px);font-weight:760;font-variant-numeric:tabular-nums;letter-spacing:-.065em;line-height:1}.stat-label[data-v-5a83d2b7]{z-index:1;align-self:end;margin-top:9px;color:var(--text-secondary);font-size:12px;font-weight:600}.stat-index[data-v-5a83d2b7]{z-index:1;grid-column:2;grid-row:1;color:var(--text-muted);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.08em}@media(max-width:1050px){.stats-grid[data-v-5a83d2b7]{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:560px){.stats-grid[data-v-5a83d2b7]{grid-template-columns:1fr}.stat-card[data-v-5a83d2b7]{min-height:150px}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.stats-grid[data-v-5214a2c8]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px}.stat-card[data-v-5214a2c8]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:24px;text-align:center}.stat-icon[data-v-5214a2c8]{font-size:28px;display:block;margin-bottom:8px}.stat-value[data-v-5214a2c8]{display:block;font-size:36px;font-weight:700;color:var(--accent)}.stat-label[data-v-5214a2c8]{font-size:14px;color:var(--text-secondary);margin-top:4px}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{_ as i,i as d,j as r,c,b as s,a as e,t as n,r as p,o as _,U as o}from"./index-BZEF5hZc.js";const u={class:"stats-grid"},v={class:"stat-card"},m={class:"stat-icon"},g={class:"stat-value"},b={class:"stat-card"},x={class:"stat-icon"},f={class:"stat-value"},y={class:"stat-card"},z={class:"stat-icon"},V={class:"stat-value"},w={class:"stat-card"},B={class:"stat-icon"},D={class:"stat-value"},k={__name:"DashboardView",setup(I){const t=p({users:0,conversations:0,messages:0,today_messages:0});return d(async()=>{const l=await r.get("/admin/stats");t.value=l.data.data}),(l,a)=>(_(),c("div",null,[a[8]||(a[8]=s("div",{class:"page-header"},[s("h2",null,"数据概览"),s("p",null,"系统运行统计数据")],-1)),s("div",u,[s("article",v,[s("span",m,[e(o,{name:"users",size:21})]),s("span",g,n(t.value.users),1),a[0]||(a[0]=s("span",{class:"stat-label"},"用户总数",-1)),a[1]||(a[1]=s("span",{class:"stat-index"},"01",-1))]),s("article",b,[s("span",x,[e(o,{name:"conversations",size:21})]),s("span",f,n(t.value.conversations),1),a[2]||(a[2]=s("span",{class:"stat-label"},"会话总数",-1)),a[3]||(a[3]=s("span",{class:"stat-index"},"02",-1))]),s("article",y,[s("span",z,[e(o,{name:"messages",size:21})]),s("span",V,n(t.value.messages),1),a[4]||(a[4]=s("span",{class:"stat-label"},"消息总数",-1)),a[5]||(a[5]=s("span",{class:"stat-index"},"03",-1))]),s("article",w,[s("span",B,[e(o,{name:"activity",size:21})]),s("span",D,n(t.value.today_messages),1),a[6]||(a[6]=s("span",{class:"stat-label"},"今日消息",-1)),a[7]||(a[7]=s("span",{class:"stat-index"},"04",-1))])])]))}},U=i(k,[["__scopeId","data-v-5a83d2b7"]]);export{U as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
.toolbar[data-v-1b3fb9c5]{margin-bottom:16px}.empty[data-v-1b3fb9c5]{text-align:center;padding:32px;color:var(--text-muted)}.danger[data-v-1b3fb9c5]{color:#ef4444}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-b11f0247]{margin-bottom:16px}.empty[data-v-b11f0247]{text-align:center;padding:32px;color:var(--text-muted)}.danger[data-v-b11f0247]{color:var(--danger)}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{_ as L,u as N,g as U,h as b,c as s,a as t,i as f,d as u,F as C,j as B,w as E,t as d,b as h,v as $,k as F,r as m,m as j,l as z,o,B as A}from"./index-z4tF8s-R.js";const I={class:"toolbar"},O={class:"panel"},R={class:"data-table"},T=["onClick"],q=["onClick"],G=["onClick"],H={key:0,class:"empty"},J={class:"modal"},K={class:"modal-header"},P={class:"modal-body"},Q={class:"form-group"},W={class:"form-group"},X=["value","disabled"],Y={class:"form-group"},Z={key:0,class:"form-error"},tt={__name:"DepartmentsView",setup(et){const c=N(),v=m([]),k=m([]),p=m(!1),r=m(null),i=m(""),l=j({name:"",parent_id:null,sort_order:0}),D=z(()=>v.value.filter(a=>a.id!==r.value));U(y);async function y(){const e=(await b.get("/admin/departments")).data.data||{};v.value=e.tree||[],k.value=e.list||[]}function V(a){var e;return a&&((e=k.value.find(n=>n.id===a))==null?void 0:e.name)||"-"}function g(a=null){r.value=null,l.name="",l.parent_id=a,l.sort_order=0,i.value="",p.value=!0}function M(a){r.value=a.id,l.name=a.name,l.parent_id=a.parent_id||null,l.sort_order=a.sort_order??0,i.value="",p.value=!0}function _(){p.value=!1}async function x(){if(i.value="",!l.name.trim()){i.value="请填写部门名称";return}const a={name:l.name.trim(),parent_id:l.parent_id,sort_order:l.sort_order};try{r.value?await b.put(`/admin/departments/${r.value}`,a):await b.post("/admin/departments",a),_(),await y()}catch(e){i.value=e.message||"保存失败"}}async function S(a){if(confirm(`确定删除部门「${a.name}」?`))try{await b.delete(`/admin/departments/${a.id}`),await y()}catch(e){alert(e.message||"删除失败")}}return(a,e)=>(o(),s("div",null,[e[9]||(e[9]=t("div",{class:"page-header"},[t("h2",null,"部门管理"),t("p",null,"维护组织部门层级,上级部门可查看下级部门员工聊天记录")],-1)),t("div",I,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-primary",onClick:e[0]||(e[0]=n=>g())},"新增部门")):u("",!0)]),t("div",O,[t("table",R,[e[4]||(e[4]=t("thead",null,[t("tr",null,[t("th",null,"部门名称"),t("th",null,"上级部门"),t("th",null,"排序"),t("th",null,"操作")])],-1)),t("tbody",null,[(o(!0),s(C,null,B(v.value,n=>(o(),s("tr",{key:n.id},[t("td",null,[t("span",{style:A({paddingLeft:`${n.depth*16}px`})},d(n.label||n.name),5)]),t("td",null,d(V(n.parent_id)),1),t("td",null,d(n.sort_order??0),1),t("td",null,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-ghost",onClick:w=>g(n.id)},"添加下级",8,T)):u("",!0),f(c).hasButton("btn:dept:edit")?(o(),s("button",{key:1,class:"btn btn-ghost",onClick:w=>M(n)},"编辑",8,q)):u("",!0),f(c).hasButton("btn:dept:delete")?(o(),s("button",{key:2,class:"btn btn-ghost danger",onClick:w=>S(n)},"删除",8,G)):u("",!0)])]))),128))])]),v.value.length?u("",!0):(o(),s("p",H,"暂无部门"))]),p.value?(o(),s("div",{key:0,class:"modal-overlay",onClick:E(_,["self"])},[t("div",J,[t("div",K,[t("h3",null,d(r.value?"编辑部门":"新增部门"),1),t("button",{onClick:_},"×")]),t("div",P,[t("div",Q,[e[5]||(e[5]=t("label",null,"部门名称",-1)),h(t("input",{"onUpdate:modelValue":e[1]||(e[1]=n=>l.name=n),class:"form-input"},null,512),[[$,l.name]])]),t("div",W,[e[7]||(e[7]=t("label",null,"上级部门",-1)),h(t("select",{"onUpdate:modelValue":e[2]||(e[2]=n=>l.parent_id=n),class:"form-select"},[e[6]||(e[6]=t("option",{value:null},"无(顶级部门)",-1)),(o(!0),s(C,null,B(D.value,n=>(o(),s("option",{key:n.id,value:n.id,disabled:r.value===n.id},d(n.label||n.name),9,X))),128))],512),[[F,l.parent_id]])]),t("div",Y,[e[8]||(e[8]=t("label",null,"排序",-1)),h(t("input",{"onUpdate:modelValue":e[3]||(e[3]=n=>l.sort_order=n),type:"number",class:"form-input"},null,512),[[$,l.sort_order,void 0,{number:!0}]])]),i.value?(o(),s("p",Z,d(i.value),1)):u("",!0)]),t("div",{class:"modal-footer"},[t("button",{class:"btn btn-ghost",onClick:_},"取消"),t("button",{class:"btn btn-primary",onClick:x},"保存")])])])):u("",!0)]))}},at=L(tt,[["__scopeId","data-v-1b3fb9c5"]]);export{at as default};
|
import{_ as L,u as N,i as U,j as b,c as s,b as t,k as f,f as u,F as w,l as $,w as E,t as d,e as k,v as B,m as F,r as m,p as j,n as z,o,C as A}from"./index-BZEF5hZc.js";const I={class:"toolbar"},O={class:"panel"},R={class:"data-table"},T=["onClick"],q=["onClick"],G=["onClick"],H={key:0,class:"empty"},J={class:"modal"},K={class:"modal-header"},P={class:"modal-body"},Q={class:"form-group"},W={class:"form-group"},X=["value","disabled"],Y={class:"form-group"},Z={key:0,class:"form-error"},tt={__name:"DepartmentsView",setup(et){const c=N(),v=m([]),h=m([]),p=m(!1),r=m(null),i=m(""),l=j({name:"",parent_id:null,sort_order:0}),D=z(()=>v.value.filter(a=>a.id!==r.value));U(y);async function y(){const e=(await b.get("/admin/departments")).data.data||{};v.value=e.tree||[],h.value=e.list||[]}function V(a){var e;return a&&((e=h.value.find(n=>n.id===a))==null?void 0:e.name)||"-"}function g(a=null){r.value=null,l.name="",l.parent_id=a,l.sort_order=0,i.value="",p.value=!0}function M(a){r.value=a.id,l.name=a.name,l.parent_id=a.parent_id||null,l.sort_order=a.sort_order??0,i.value="",p.value=!0}function _(){p.value=!1}async function x(){if(i.value="",!l.name.trim()){i.value="请填写部门名称";return}const a={name:l.name.trim(),parent_id:l.parent_id,sort_order:l.sort_order};try{r.value?await b.put(`/admin/departments/${r.value}`,a):await b.post("/admin/departments",a),_(),await y()}catch(e){i.value=e.message||"保存失败"}}async function S(a){if(confirm(`确定删除部门「${a.name}」?`))try{await b.delete(`/admin/departments/${a.id}`),await y()}catch(e){alert(e.message||"删除失败")}}return(a,e)=>(o(),s("div",null,[e[9]||(e[9]=t("div",{class:"page-header"},[t("h2",null,"部门管理"),t("p",null,"维护组织部门层级,上级部门可查看下级部门员工聊天记录")],-1)),t("div",I,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-primary",onClick:e[0]||(e[0]=n=>g())},"新增部门")):u("",!0)]),t("div",O,[t("table",R,[e[4]||(e[4]=t("thead",null,[t("tr",null,[t("th",null,"部门名称"),t("th",null,"上级部门"),t("th",null,"排序"),t("th",null,"操作")])],-1)),t("tbody",null,[(o(!0),s(w,null,$(v.value,n=>(o(),s("tr",{key:n.id},[t("td",null,[t("span",{style:A({paddingLeft:`${n.depth*16}px`})},d(n.label||n.name),5)]),t("td",null,d(V(n.parent_id)),1),t("td",null,d(n.sort_order??0),1),t("td",null,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-ghost",onClick:C=>g(n.id)},"添加下级",8,T)):u("",!0),f(c).hasButton("btn:dept:edit")?(o(),s("button",{key:1,class:"btn btn-ghost",onClick:C=>M(n)},"编辑",8,q)):u("",!0),f(c).hasButton("btn:dept:delete")?(o(),s("button",{key:2,class:"btn btn-ghost danger",onClick:C=>S(n)},"删除",8,G)):u("",!0)])]))),128))])]),v.value.length?u("",!0):(o(),s("p",H,"暂无部门"))]),p.value?(o(),s("div",{key:0,class:"modal-overlay",onClick:E(_,["self"])},[t("div",J,[t("div",K,[t("h3",null,d(r.value?"编辑部门":"新增部门"),1),t("button",{onClick:_},"×")]),t("div",P,[t("div",Q,[e[5]||(e[5]=t("label",null,"部门名称",-1)),k(t("input",{"onUpdate:modelValue":e[1]||(e[1]=n=>l.name=n),class:"form-input"},null,512),[[B,l.name]])]),t("div",W,[e[7]||(e[7]=t("label",null,"上级部门",-1)),k(t("select",{"onUpdate:modelValue":e[2]||(e[2]=n=>l.parent_id=n),class:"form-select"},[e[6]||(e[6]=t("option",{value:null},"无(顶级部门)",-1)),(o(!0),s(w,null,$(D.value,n=>(o(),s("option",{key:n.id,value:n.id,disabled:r.value===n.id},d(n.label||n.name),9,X))),128))],512),[[F,l.parent_id]])]),t("div",Y,[e[8]||(e[8]=t("label",null,"排序",-1)),k(t("input",{"onUpdate:modelValue":e[3]||(e[3]=n=>l.sort_order=n),type:"number",class:"form-input"},null,512),[[B,l.sort_order,void 0,{number:!0}]])]),i.value?(o(),s("p",Z,d(i.value),1)):u("",!0)]),t("div",{class:"modal-footer"},[t("button",{class:"btn btn-ghost",onClick:_},"取消"),t("button",{class:"btn btn-primary",onClick:x},"保存")])])])):u("",!0)]))}},at=L(tt,[["__scopeId","data-v-b11f0247"]]);export{at as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.login-page[data-v-d42d96d7]{position:relative;min-height:100dvh;display:grid;place-items:center;padding:28px;overflow:hidden;background:radial-gradient(circle at 70% 20%,rgba(183,243,107,.08),transparent 24%),var(--bg-primary)}.login-theme-toggle[data-v-d42d96d7]{position:absolute;top:18px;right:18px;z-index:3}.login-shell[data-v-d42d96d7]{position:relative;display:grid;width:min(880px,100%);grid-template-columns:1.08fr .92fr;overflow:hidden;border:1px solid var(--border-strong);border-radius:22px;background:var(--bg-secondary);box-shadow:inset 0 1px #ffffff0d,var(--shadow)}.login-shell[data-v-d42d96d7]:after{position:absolute;top:0;left:18%;width:36%;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);box-shadow:0 0 17px #b7f36b70;content:""}.login-aside[data-v-d42d96d7]{position:relative;display:flex;min-height:530px;flex-direction:column;justify-content:center;padding:52px;overflow:hidden;border-right:1px solid var(--border);background:linear-gradient(135deg,rgba(183,243,107,.07),transparent 45%),repeating-linear-gradient(135deg,rgba(255,255,255,.018) 0 1px,transparent 1px 14px),#0c0f13}.login-mark[data-v-d42d96d7]{display:grid;width:46px;height:46px;place-items:center;margin-bottom:44px;border:1px solid rgba(183,243,107,.48);border-radius:14px;background:var(--accent);color:#11150e;box-shadow:inset 0 1px #ffffff80,0 5px #55782f,0 14px 28px #6ea6362b}.login-eyebrow[data-v-d42d96d7],.login-version[data-v-d42d96d7]{color:var(--accent);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.16em}.login-aside h1[data-v-d42d96d7]{margin:15px 0 18px;font-size:clamp(34px,4.6vw,52px);font-weight:760;letter-spacing:-.055em;line-height:1.04;text-wrap:balance}.login-aside p[data-v-d42d96d7]{max-width:32ch;color:var(--text-secondary);font-size:14px;line-height:1.75}.login-version[data-v-d42d96d7]{position:absolute;bottom:28px;left:52px;color:var(--text-muted);font-size:9px}.login-card[data-v-d42d96d7]{display:flex;flex-direction:column;justify-content:center;padding:48px 42px;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.055),transparent 26%),var(--bg-secondary)}.login-header[data-v-d42d96d7]{margin-bottom:30px}.login-header>span[data-v-d42d96d7]:not(.status-dot){color:var(--text-muted);font-size:11px;font-weight:650;letter-spacing:.08em}.status-dot[data-v-d42d96d7]{display:inline-block;width:7px;height:7px;margin-right:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #b7f36b99}.login-header h2[data-v-d42d96d7]{margin:12px 0 7px;font-size:26px;letter-spacing:-.04em}.login-header p[data-v-d42d96d7]{color:var(--text-secondary);font-size:13px}.login-btn[data-v-d42d96d7]{width:100%;min-height:46px;margin-top:7px}@media(max-width:720px){.login-page[data-v-d42d96d7]{padding:14px}.login-shell[data-v-d42d96d7]{grid-template-columns:1fr}.login-aside[data-v-d42d96d7]{display:none}.login-card[data-v-d42d96d7]{min-height:520px;padding:38px 25px}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{_ as y,u as w,c as u,a as d,b as s,d as p,w as V,e as v,v as g,t as c,f as T,r as t,g as N,h as x,o as m,T as A,U}from"./index-BZEF5hZc.js";const k={class:"login-page"},C={class:"login-shell"},I={class:"login-aside"},L={class:"login-mark"},R={class:"login-card"},S={class:"form-group"},q={class:"form-group"},B={key:0,class:"form-error"},D=["disabled"],M={__name:"LoginView",setup(E){const f=N(),b=x(),_=w(),a=t(""),n=t(""),o=t(""),l=t(!1);async function h(){o.value="",l.value=!0;try{await _.login(a.value,n.value),f.push(b.query.redirect||"/dashboard")}catch(r){o.value=r.message}finally{l.value=!1}}return(r,e)=>(m(),u("div",k,[d(A,{class:"login-theme-toggle"}),s("div",C,[s("div",I,[s("span",L,[d(U,{name:"bolt",size:22})]),e[2]||(e[2]=s("span",{class:"login-eyebrow"},"AI CHAT / ADMIN",-1)),e[3]||(e[3]=s("h1",null,[p("让系统配置"),s("br"),p("保持清晰可控")],-1)),e[4]||(e[4]=s("p",null,"统一管理用户、模型、权限与会话数据。",-1)),e[5]||(e[5]=s("span",{class:"login-version"},"CONTROL SURFACE · V2",-1))]),s("div",R,[e[8]||(e[8]=s("div",{class:"login-header"},[s("span",{class:"status-dot","aria-hidden":"true"}),s("span",null,"安全入口"),s("h2",null,"登录管理后台"),s("p",null,"请使用管理员账户继续")],-1)),s("form",{onSubmit:V(h,["prevent"])},[s("div",S,[e[6]||(e[6]=s("label",null,"账号",-1)),v(s("input",{"onUpdate:modelValue":e[0]||(e[0]=i=>a.value=i),class:"form-input",placeholder:"管理员用户名或邮箱",required:""},null,512),[[g,a.value]])]),s("div",q,[e[7]||(e[7]=s("label",null,"密码",-1)),v(s("input",{"onUpdate:modelValue":e[1]||(e[1]=i=>n.value=i),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[g,n.value]])]),o.value?(m(),u("p",B,c(o.value),1)):T("",!0),s("button",{type:"submit",class:"btn btn-primary login-btn",disabled:l.value},c(l.value?"登录中...":"进入控制台"),9,D)],32)])])]))}},z=y(M,[["__scopeId","data-v-d42d96d7"]]);export{z as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{_ as b,u as h,c as i,a as e,w as y,b as d,v as p,t as c,d as w,r as a,e as V,f as x,o as v}from"./index-z4tF8s-R.js";const k={class:"login-page"},q={class:"login-card"},B={class:"form-group"},L={class:"form-group"},S={key:0,class:"form-error"},A=["disabled"],C={__name:"LoginView",setup(D){const f=V(),m=x(),g=h(),l=a(""),n=a(""),o=a(""),t=a(!1);async function _(){o.value="",t.value=!0;try{await g.login(l.value,n.value),f.push(m.query.redirect||"/dashboard")}catch(u){o.value=u.message}finally{t.value=!1}}return(u,s)=>(v(),i("div",k,[e("div",q,[s[4]||(s[4]=e("div",{class:"login-header"},[e("div",{class:"logo"},"⚙️"),e("h1",null,"AI Chat 管理后台"),e("p",null,"请使用管理员账户登录")],-1)),e("form",{onSubmit:y(_,["prevent"])},[e("div",B,[s[2]||(s[2]=e("label",null,"账号",-1)),d(e("input",{"onUpdate:modelValue":s[0]||(s[0]=r=>l.value=r),class:"form-input",placeholder:"管理员用户名或邮箱",required:""},null,512),[[p,l.value]])]),e("div",L,[s[3]||(s[3]=e("label",null,"密码",-1)),d(e("input",{"onUpdate:modelValue":s[1]||(s[1]=r=>n.value=r),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[p,n.value]])]),o.value?(v(),i("p",S,c(o.value),1)):w("",!0),e("button",{type:"submit",class:"btn btn-primary login-btn",disabled:t.value},c(t.value?"登录中...":"登录"),9,A)],32)])]))}},M=b(C,[["__scopeId","data-v-7f135f83"]]);export{M as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.login-page[data-v-7f135f83]{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;background:linear-gradient(135deg,#0f172a,#1e1b4b)}.login-card[data-v-7f135f83]{width:100%;max-width:400px;padding:40px 32px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:16px}.login-header[data-v-7f135f83]{text-align:center;margin-bottom:32px}.logo[data-v-7f135f83]{font-size:48px;margin-bottom:12px}.login-header h1[data-v-7f135f83]{font-size:22px;margin-bottom:8px}.login-header p[data-v-7f135f83]{color:var(--text-secondary);font-size:14px}.login-btn[data-v-7f135f83]{width:100%;padding:12px;margin-top:8px}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.toolbar[data-v-b7a460a8]{margin-bottom:16px}.membership-grid[data-v-b7a460a8]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}.membership-card[data-v-b7a460a8]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:20px}.card-header[data-v-b7a460a8]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.card-header h3[data-v-b7a460a8]{font-size:18px}.slug[data-v-b7a460a8]{font-size:12px;color:var(--text-muted);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px}.info-row[data-v-b7a460a8]{display:flex;justify-content:space-between;padding:8px 0;font-size:14px;border-bottom:1px solid var(--border)}.info-row span[data-v-b7a460a8]{color:var(--text-secondary)}.permissions[data-v-b7a460a8]{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px;min-height:24px}.perm-tag[data-v-b7a460a8]{font-size:12px;padding:2px 8px;background:#6366f126;color:var(--accent);border-radius:4px}.field-hint[data-v-b7a460a8]{font-size:12px;color:var(--text-muted)}.card-actions[data-v-b7a460a8]{display:flex;gap:8px;margin-top:16px}.card-actions .btn[data-v-b7a460a8]{flex:1}.check-item[data-v-b7a460a8]{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:14px;cursor:pointer}.danger[data-v-b7a460a8]{color:#ef4444}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-cc64fdb8]{margin-bottom:16px}.membership-grid[data-v-cc64fdb8]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}.membership-card[data-v-cc64fdb8]{position:relative;overflow:hidden;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.05),transparent 30%),var(--bg-secondary);border:1px solid var(--border);border-radius:16px;padding:20px;box-shadow:inset 0 1px #ffffff09,var(--shadow-soft);transition:transform .2s var(--ease-spring),border-color .18s ease,box-shadow .18s ease}.membership-card[data-v-cc64fdb8]:hover{border-color:#b7f36b38;transform:translateY(-3px);box-shadow:inset 0 1px #ffffff0b,0 22px 52px #00000057}.card-header[data-v-cc64fdb8]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.card-header h3[data-v-cc64fdb8]{font-size:18px}.slug[data-v-cc64fdb8]{font-size:12px;color:var(--text-muted);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px}.info-row[data-v-cc64fdb8]{display:flex;justify-content:space-between;padding:8px 0;font-size:14px;border-bottom:1px solid var(--border)}.info-row span[data-v-cc64fdb8]{color:var(--text-secondary)}.permissions[data-v-cc64fdb8]{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px;min-height:24px}.perm-tag[data-v-cc64fdb8]{font-size:12px;padding:2px 8px;border:1px solid rgba(183,243,107,.22);background:var(--accent-soft);color:var(--accent);border-radius:4px}.field-hint[data-v-cc64fdb8]{font-size:12px;color:var(--text-muted)}.card-actions[data-v-cc64fdb8]{display:flex;gap:8px;margin-top:16px}.card-actions .btn[data-v-cc64fdb8]{flex:1}.check-item[data-v-cc64fdb8]{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:14px;cursor:pointer}.danger[data-v-cc64fdb8]{color:var(--danger)}
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.toolbar[data-v-f8ed284c]{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}.empty[data-v-f8ed284c]{text-align:center;padding:32px;color:var(--text-muted)}.type-badge[data-v-f8ed284c]{font-size:11px;padding:2px 6px;border-radius:4px}.type-badge.dir[data-v-f8ed284c]{background:#0ea5e926;color:#0ea5e9}.type-badge.menu[data-v-f8ed284c]{background:#6366f126;color:var(--accent)}.type-badge.btn[data-v-f8ed284c]{background:#22c55e26;color:#22c55e}.field-hint[data-v-f8ed284c]{margin-top:6px;font-size:12px;color:var(--text-muted)}.danger[data-v-f8ed284c]{color:#ef4444}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-0259ca3f]{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}.empty[data-v-0259ca3f]{text-align:center;padding:32px;color:var(--text-muted)}.type-badge[data-v-0259ca3f]{font-size:11px;padding:2px 6px;border-radius:4px}.type-badge.dir[data-v-0259ca3f]{background:#ffffff0b;color:var(--text-secondary)}.type-badge.menu[data-v-0259ca3f]{background:var(--accent-soft);color:var(--accent)}.type-badge.btn[data-v-0259ca3f]{background:#8fe06a14;color:var(--success)}.path-cell[data-v-0259ca3f]{display:flex;align-items:center;gap:8px;color:var(--text-secondary);font-family:Cascadia Code,Consolas,monospace;font-size:12px}.path-cell[data-v-0259ca3f] svg{color:var(--accent)}.field-hint[data-v-0259ca3f]{margin-top:6px;font-size:12px;color:var(--text-muted)}.danger[data-v-0259ca3f]{color:var(--danger)}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-b204ae6d]{margin-bottom:16px}.perm-tags[data-v-b204ae6d]{display:flex;flex-wrap:wrap;gap:4px}.perm-tag[data-v-b204ae6d]{font-size:12px;padding:2px 8px;background:var(--accent-soft);color:var(--accent);border-radius:4px}.field-hint[data-v-b204ae6d]{font-size:12px;color:var(--text-muted)}.modal-wide[data-v-b204ae6d]{max-width:640px;max-height:90vh;display:flex;flex-direction:column}.modal-wide .modal-body[data-v-b204ae6d]{overflow-y:auto}.top-check[data-v-b204ae6d]{font-weight:500}.perm-section[data-v-b204ae6d]{border:1px solid var(--border);border-radius:10px;padding:12px;background:var(--bg-tertiary)}.perm-section-header[data-v-b204ae6d]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:13px;font-weight:500}.perm-actions[data-v-b204ae6d]{display:flex;gap:4px}.perm-dir[data-v-b204ae6d]{margin-bottom:14px;padding-bottom:10px;border-bottom:1px dashed var(--border)}.perm-dir[data-v-b204ae6d]:last-child{border-bottom:none;margin-bottom:0}.dir-check[data-v-b204ae6d]{font-weight:600;margin-bottom:8px}.perm-menu[data-v-b204ae6d]{margin-left:22px;margin-bottom:8px}.perm-btns[data-v-b204ae6d]{margin-left:24px;display:flex;flex-direction:column;gap:4px}.btn-check[data-v-b204ae6d]{font-size:13px;color:var(--text-secondary)}.check-item[data-v-b204ae6d]{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:14px;cursor:pointer}.type-badge[data-v-b204ae6d]{font-size:11px;padding:1px 6px;border-radius:4px;font-weight:500}.type-badge.dir[data-v-b204ae6d]{background:#b7f36b12;color:var(--text-secondary)}.type-badge.menu[data-v-b204ae6d]{background:var(--accent-soft);color:var(--accent)}.type-badge.btn[data-v-b204ae6d]{background:#8fe06a14;color:var(--success)}.danger[data-v-b204ae6d]{color:var(--danger)}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.toolbar[data-v-fc345f7d]{margin-bottom:16px}.perm-tags[data-v-fc345f7d]{display:flex;flex-wrap:wrap;gap:4px}.perm-tag[data-v-fc345f7d]{font-size:12px;padding:2px 8px;background:#6366f126;color:var(--accent);border-radius:4px}.field-hint[data-v-fc345f7d]{font-size:12px;color:var(--text-muted)}.modal-wide[data-v-fc345f7d]{max-width:640px;max-height:90vh;display:flex;flex-direction:column}.modal-wide .modal-body[data-v-fc345f7d]{overflow-y:auto}.top-check[data-v-fc345f7d]{font-weight:500}.perm-section[data-v-fc345f7d]{border:1px solid var(--border);border-radius:10px;padding:12px;background:var(--bg-tertiary)}.perm-section-header[data-v-fc345f7d]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:13px;font-weight:500}.perm-actions[data-v-fc345f7d]{display:flex;gap:4px}.perm-dir[data-v-fc345f7d]{margin-bottom:14px;padding-bottom:10px;border-bottom:1px dashed var(--border)}.perm-dir[data-v-fc345f7d]:last-child{border-bottom:none;margin-bottom:0}.dir-check[data-v-fc345f7d]{font-weight:600;margin-bottom:8px}.perm-menu[data-v-fc345f7d]{margin-left:22px;margin-bottom:8px}.perm-btns[data-v-fc345f7d]{margin-left:24px;display:flex;flex-direction:column;gap:4px}.btn-check[data-v-fc345f7d]{font-size:13px;color:var(--text-secondary)}.check-item[data-v-fc345f7d]{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:14px;cursor:pointer}.type-badge[data-v-fc345f7d]{font-size:11px;padding:1px 6px;border-radius:4px;font-weight:500}.type-badge.dir[data-v-fc345f7d]{background:#0ea5e926;color:#0ea5e9}.type-badge.menu[data-v-fc345f7d]{background:#6366f126;color:var(--accent)}.type-badge.btn[data-v-fc345f7d]{background:#22c55e26;color:#22c55e}.danger[data-v-fc345f7d]{color:#ef4444}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{_ as V,u as h,g as y,h as c,c as o,a as t,b as p,v as j,x as m,s as B,F as C,j as M,i as N,t as _,d as f,r as v,m as S,o as u}from"./index-z4tF8s-R.js";const U={class:"panel"},A={class:"form-group"},T={class:"form-group"},D={class:"check-item"},F={class:"panel",style:{"margin-top":"16px"}},I={class:"feature-grid"},L=["onUpdate:modelValue"],E=["disabled"],O={key:1,class:"success-msg"},R={__name:"SettingsView",setup(q){const g=h(),i=v("AI Chat"),n=v(!0),r=v(!1),d=v(!1),a=S({markdown:!0,image:!0,video:!0,voice:!0,document:!0,emoji:!0,upload_image:!0,upload_video:!0,upload_file:!0,paste_image:!0}),b={markdown:"Markdown 解析",image:"图片解析",video:"视频解析",voice:"语音解析",document:"文档解析",emoji:"表情",upload_image:"上传图片",upload_video:"上传视频",upload_file:"上传文件",paste_image:"粘贴图片"};y(async()=>{var s;const e=(await c.get("/admin/settings")).data.data;e.site_name&&(i.value=e.site_name.value),e.allow_register&&(n.value=e.allow_register.value===!0||e.allow_register.value==="true"),(s=e.features)!=null&&s.value&&Object.assign(a,e.features.value)});async function w(){r.value=!0,d.value=!1;try{await c.put("/admin/settings",{site_name:i.value,allow_register:n.value?"true":"false",features:{...a}}),d.value=!0,setTimeout(()=>{d.value=!1},3e3)}finally{r.value=!1}}return(x,e)=>(u(),o("div",null,[e[7]||(e[7]=t("div",{class:"page-header"},[t("h2",null,"系统设置"),t("p",null,"控制前端功能开关与站点配置")],-1)),t("div",U,[e[4]||(e[4]=t("h3",{class:"section-title"},"站点配置",-1)),t("div",A,[e[2]||(e[2]=t("label",null,"站点名称",-1)),p(t("input",{"onUpdate:modelValue":e[0]||(e[0]=s=>i.value=s),class:"form-input"},null,512),[[j,i.value]])]),t("div",T,[t("label",D,[p(t("input",{type:"checkbox","onUpdate:modelValue":e[1]||(e[1]=s=>n.value=s)},null,512),[[m,n.value]]),e[3]||(e[3]=B(" 允许用户注册 ",-1))])])]),t("div",F,[e[5]||(e[5]=t("h3",{class:"section-title"},"功能开关(会员端)",-1)),e[6]||(e[6]=t("p",{class:"section-desc"},"关闭后,会员端对应功能将不可用",-1)),t("div",I,[(u(!0),o(C,null,M(a,(s,l)=>(u(),o("label",{key:l,class:"feature-item"},[p(t("input",{type:"checkbox","onUpdate:modelValue":k=>a[l]=k},null,8,L),[[m,a[l]]]),t("span",null,_(b[l]||l),1)]))),128))]),N(g).hasButton("btn:settings:save")?(u(),o("button",{key:0,class:"btn btn-primary",onClick:w,disabled:r.value},_(r.value?"保存中...":"保存全部设置"),9,E)):f("",!0),d.value?(u(),o("p",O,"保存成功")):f("",!0)])]))}},G=V(R,[["__scopeId","data-v-75cd9393"]]);export{G as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.section-title[data-v-75cd9393]{font-size:16px;margin-bottom:16px}.section-desc[data-v-75cd9393]{font-size:13px;color:var(--text-secondary);margin-bottom:16px}.feature-grid[data-v-75cd9393]{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px}.feature-item[data-v-75cd9393]{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer}.feature-item input[data-v-75cd9393]{accent-color:var(--accent)}.check-item[data-v-75cd9393]{display:flex;align-items:center;gap:8px;cursor:pointer}.success-msg[data-v-75cd9393]{color:var(--success);font-size:14px;margin-top:12px}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.section-title[data-v-0509a01f]{margin-bottom:16px;font-size:16px}.section-desc[data-v-0509a01f],.field-hint[data-v-0509a01f]{color:var(--text-secondary);font-size:12px}.section-desc[data-v-0509a01f]{margin-bottom:16px;font-size:13px}.voice-persona-panel[data-v-0509a01f],.feature-panel[data-v-0509a01f]{margin-top:16px}.persona-heading[data-v-0509a01f]{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding-bottom:20px;border-bottom:1px solid var(--border)}.persona-preview[data-v-0509a01f]{display:flex;align-items:center;gap:14px;min-width:0}.persona-avatar[data-v-0509a01f]{width:54px;height:54px;flex:0 0 54px;border-radius:18px;display:grid;place-items:center;border:1px solid rgba(183,243,107,.42);background:linear-gradient(145deg,var(--accent-hover),var(--accent));box-shadow:inset 0 1px #ffffff73,0 5px #577c2f,0 13px 26px #6ea63629;color:#11150e;font-size:22px;font-weight:700}.persona-kicker[data-v-0509a01f]{color:var(--accent);font-size:10px;font-weight:700;letter-spacing:.13em}.persona-preview h3[data-v-0509a01f]{margin-top:3px;font-size:18px}.persona-preview p[data-v-0509a01f]{margin-top:4px;color:var(--text-secondary);font-size:13px;line-height:1.5}.enable-switch[data-v-0509a01f]{display:inline-flex;align-items:center;gap:8px;padding:8px 11px;border:1px solid var(--border);border-radius:999px;color:var(--text-secondary);font-size:12px;white-space:nowrap}.enable-switch input[data-v-0509a01f],.feature-item input[data-v-0509a01f],.check-item input[data-v-0509a01f]{accent-color:var(--accent)}.preset-row[data-v-0509a01f]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:18px 0}.preset-row>span[data-v-0509a01f]{margin-right:4px;color:var(--text-secondary);font-size:12px}.preset-btn[data-v-0509a01f]{padding:7px 11px;border:1px solid var(--border);border-radius:8px;background:#ffffff05;color:var(--text-secondary);font-size:12px}.preset-btn[data-v-0509a01f]:hover{border-color:#b7f36b66;background:var(--accent-soft);color:var(--accent)}.form-grid[data-v-0509a01f]{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-grid-3[data-v-0509a01f]{grid-template-columns:1fr 1fr .7fr}.persona-textarea[data-v-0509a01f]{min-height:86px;resize:vertical;line-height:1.55}.field-hint[data-v-0509a01f]{margin-top:6px;line-height:1.5}.input-suffix[data-v-0509a01f]{position:relative}.input-suffix input[data-v-0509a01f]{padding-right:38px}.input-suffix span[data-v-0509a01f]{position:absolute;top:50%;right:12px;color:var(--text-muted);font-size:12px;transform:translateY(-50%)}.reference-box[data-v-0509a01f]{margin-bottom:16px;padding:14px;border:1px dashed var(--border-strong);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:16px;background:#b7f36b09}.reference-box strong[data-v-0509a01f]{font-size:13px}.reference-box p[data-v-0509a01f]{margin-top:4px;color:var(--text-secondary);font-size:12px;line-height:1.5}.reference-btn[data-v-0509a01f]{flex:0 0 auto;border:1px solid var(--border)}.persona-actions[data-v-0509a01f]{min-height:38px;display:flex;align-items:center;flex-wrap:wrap;gap:12px}.preview-btn[data-v-0509a01f]{border:1px solid var(--border-strong)}.preview-audio[data-v-0509a01f]{width:min(320px,100%);height:36px}.voice-status[data-v-0509a01f]{color:var(--success);font-size:12px}.voice-status.error[data-v-0509a01f]{color:var(--danger)}.feature-grid[data-v-0509a01f]{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px}.feature-item[data-v-0509a01f],.check-item[data-v-0509a01f]{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px}.success-msg[data-v-0509a01f]{margin-top:12px;color:var(--success);font-size:14px}@media(max-width:760px){.persona-heading[data-v-0509a01f],.reference-box[data-v-0509a01f]{align-items:stretch;flex-direction:column}.enable-switch[data-v-0509a01f]{align-self:flex-start}.form-grid[data-v-0509a01f],.form-grid-3[data-v-0509a01f]{grid-template-columns:1fr}}
|
||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.toolbar[data-v-855121f7]{margin-bottom:16px}.empty[data-v-855121f7]{text-align:center;padding:32px;color:var(--text-muted)}.pagination[data-v-855121f7]{display:flex;align-items:center;justify-content:center;gap:16px;margin-top:16px;font-size:13px;color:var(--text-secondary)}.modal-wide[data-v-855121f7]{max-width:560px}.user-meta[data-v-855121f7]{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:16px;padding:10px 12px;background:var(--bg-tertiary);border-radius:8px;font-size:13px;color:var(--text-secondary)}.form-divider[data-v-855121f7]{margin:20px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);font-size:13px;font-weight:500;color:var(--text-secondary)}.membership-preview[data-v-855121f7]{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.perm-tag[data-v-855121f7]{font-size:12px;padding:2px 8px;background:var(--accent-soft);color:var(--accent);border-radius:4px}.field-hint[data-v-855121f7]{font-size:12px;color:var(--text-muted)}.danger[data-v-855121f7]{color:var(--danger)}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.toolbar[data-v-921bbcc9]{margin-bottom:16px}.empty[data-v-921bbcc9]{text-align:center;padding:32px;color:var(--text-muted)}.pagination[data-v-921bbcc9]{display:flex;align-items:center;justify-content:center;gap:16px;margin-top:16px;font-size:13px;color:var(--text-secondary)}.modal-wide[data-v-921bbcc9]{max-width:560px}.user-meta[data-v-921bbcc9]{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:16px;padding:10px 12px;background:var(--bg-tertiary);border-radius:8px;font-size:13px;color:var(--text-secondary)}.form-divider[data-v-921bbcc9]{margin:20px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);font-size:13px;font-weight:500;color:var(--text-secondary)}.membership-preview[data-v-921bbcc9]{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.perm-tag[data-v-921bbcc9]{font-size:12px;padding:2px 8px;background:#6366f126;color:var(--accent);border-radius:4px}.field-hint[data-v-921bbcc9]{font-size:12px;color:var(--text-muted)}.danger[data-v-921bbcc9]{color:#ef4444}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI Chat 管理后台</title>
|
<title>AI Chat 管理后台</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||||
<script type="module" crossorigin src="/admin/assets/index-z4tF8s-R.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-BZEF5hZc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-DPq65Hqk.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-B4BiP-qK.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{_ as V,u as k,o as E,c as v,a as i,b as t,d as f,e,t as d,w as S,f as c,v as g,g as x,h as A,r as l,i as I,j as N,k as C,l as h}from"./index-sdqi2xzF.js";import{u as M,I as q,T as B}from"./settings-R2Yjxl-Z.js";const L={class:"auth-page"},R={class:"auth-shell"},D={class:"auth-story"},G={class:"auth-logo"},P={class:"auth-card"},U={class:"auth-header"},j={class:"form-group"},z={class:"form-group"},X={key:0,class:"form-error"},F=["disabled"],H={class:"auth-footer"},J={__name:"LoginView",setup(K){const _=N(),b=C(),w=k(),p=M(),n=l(""),u=l(""),o=l(""),a=l(!1);E(()=>p.loadPublic());async function y(){o.value="",a.value=!0;try{await w.login(n.value,u.value),_.push(b.query.redirect||"/")}catch(m){o.value=m.message}finally{a.value=!1}}return(m,s)=>{const T=I("router-link");return h(),v("div",L,[i(B,{class:"auth-theme-toggle"}),t("section",R,[t("aside",D,[t("span",G,[i(f(q),{size:22,"stroke-width":1.8})]),s[2]||(s[2]=t("span",{class:"auth-eyebrow"},"AI CREATIVE SPACE",-1)),s[3]||(s[3]=t("h1",null,[e("把想法放进来,"),t("br"),e("让创作自然发生。")],-1)),s[4]||(s[4]=t("p",null,"在同一个工作区完成对话、图像生成与内容整理。",-1)),s[5]||(s[5]=t("span",{class:"auth-note"},"TEXT · IMAGE · AGENT",-1))]),t("div",P,[t("div",U,[s[6]||(s[6]=t("span",{class:"auth-status"},[t("i"),e(" 账户登录")],-1)),t("h2",null,d(f(p).siteName),1),s[7]||(s[7]=t("p",null,"欢迎回来,请登录您的账户",-1))]),t("form",{onSubmit:S(y,["prevent"])},[t("div",j,[s[8]||(s[8]=t("label",null,"账号",-1)),c(t("input",{"onUpdate:modelValue":s[0]||(s[0]=r=>n.value=r),class:"form-input",placeholder:"用户名或邮箱",required:""},null,512),[[g,n.value]])]),t("div",z,[s[9]||(s[9]=t("label",null,"密码",-1)),c(t("input",{"onUpdate:modelValue":s[1]||(s[1]=r=>u.value=r),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[g,u.value]])]),o.value?(h(),v("p",X,d(o.value),1)):x("",!0),t("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:a.value},d(a.value?"登录中...":"进入创作空间"),9,F)],32),t("p",H,[s[11]||(s[11]=e(" 还没有账户? ",-1)),i(T,{to:"/register"},{default:A(()=>[...s[10]||(s[10]=[e("立即注册",-1)])]),_:1})])])])])}}},W=V(J,[["__scopeId","data-v-036d1210"]]);export{W as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.auth-page[data-v-036d1210]{position:relative;min-height:100dvh;display:grid;place-items:center;padding:28px;overflow:auto;background:radial-gradient(circle at 72% 16%,rgba(183,243,107,.08),transparent 26%),var(--bg-primary)}.auth-theme-toggle[data-v-036d1210]{position:absolute;top:18px;right:18px;z-index:3}.auth-shell[data-v-036d1210]{position:relative;display:grid;width:min(900px,100%);grid-template-columns:1.08fr .92fr;overflow:hidden;border:1px solid var(--border-strong);border-radius:22px;background:var(--bg-secondary);box-shadow:inset 0 1px #ffffff0b,var(--shadow)}.auth-shell[data-v-036d1210]:after{position:absolute;top:0;left:16%;width:36%;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);box-shadow:0 0 17px #b7f36b70;content:""}.auth-story[data-v-036d1210]{position:relative;display:flex;min-height:550px;flex-direction:column;justify-content:center;padding:54px;overflow:hidden;border-right:1px solid var(--border);background:linear-gradient(135deg,rgba(183,243,107,.07),transparent 45%),repeating-linear-gradient(135deg,rgba(255,255,255,.018) 0 1px,transparent 1px 14px),#0c0f13}.auth-logo[data-v-036d1210]{display:grid;width:46px;height:46px;place-items:center;margin-bottom:46px;border:1px solid rgba(183,243,107,.5);border-radius:14px;background:var(--accent);color:#11150e;box-shadow:inset 0 1px #ffffff85,0 5px #55782f,0 14px 28px #6ea6362b}.auth-eyebrow[data-v-036d1210],.auth-note[data-v-036d1210]{color:var(--accent);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.16em}.auth-story h1[data-v-036d1210]{margin:15px 0 19px;font-size:clamp(36px,4.7vw,54px);font-weight:760;letter-spacing:-.06em;line-height:1.04;text-wrap:balance}.auth-story p[data-v-036d1210]{max-width:31ch;color:var(--text-secondary);font-size:14px;line-height:1.75}.auth-note[data-v-036d1210]{position:absolute;bottom:30px;left:54px;color:var(--text-muted);font-size:9px}.auth-card[data-v-036d1210]{display:flex;flex-direction:column;justify-content:center;padding:48px 42px;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.05),transparent 28%),var(--bg-secondary)}.auth-header[data-v-036d1210]{margin-bottom:30px}.auth-status[data-v-036d1210]{display:inline-flex;align-items:center;gap:7px;color:var(--text-muted);font-size:11px;font-weight:650;letter-spacing:.06em}.auth-status i[data-v-036d1210]{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #b7f36b99}.auth-header h2[data-v-036d1210]{margin:12px 0 7px;font-size:27px;letter-spacing:-.045em}.auth-header p[data-v-036d1210],.auth-footer[data-v-036d1210]{color:var(--text-secondary);font-size:13px}.auth-btn[data-v-036d1210]{width:100%;min-height:47px;margin-top:7px}.auth-footer[data-v-036d1210]{margin-top:25px;text-align:center}@media(max-width:720px){.auth-page[data-v-036d1210]{padding:14px}.auth-shell[data-v-036d1210]{grid-template-columns:1fr}.auth-story[data-v-036d1210]{display:none}.auth-card[data-v-036d1210]{min-height:540px;padding:40px 25px}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.auth-page[data-v-a92a8550]{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--bg-secondary)}.auth-card[data-v-a92a8550]{width:100%;max-width:400px;padding:40px 32px;background:var(--bg-primary);border-radius:16px;border:1px solid var(--border);box-shadow:var(--shadow)}.auth-header[data-v-a92a8550]{text-align:center;margin-bottom:32px}.logo[data-v-a92a8550]{font-size:48px;margin-bottom:12px}.auth-header h1[data-v-a92a8550]{font-size:24px;margin-bottom:8px}.auth-header p[data-v-a92a8550]{color:var(--text-secondary);font-size:14px}.auth-btn[data-v-a92a8550]{width:100%;margin-top:8px;padding:14px}.auth-footer[data-v-a92a8550]{text-align:center;margin-top:24px;font-size:14px;color:var(--text-secondary)}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{_ as y,u as V,o as k,c as p,a as t,t as u,b as x,w as N,d as v,v as c,e as S,f as m,g as q,h as B,r as a,i as C,j as L,k as M,l as f}from"./index-k46zOoYG.js";import{u as D}from"./settings-1-hPceiv.js";const R={class:"auth-page"},T={class:"auth-card"},U={class:"auth-header"},j={class:"form-group"},A={class:"form-group"},E={key:0,class:"form-error"},I=["disabled"],P={class:"auth-footer"},z={__name:"LoginView",setup(F){const _=L(),g=M(),b=V(),i=D(),l=a(""),r=a(""),s=a(""),o=a(!1);k(()=>i.loadPublic());async function h(){s.value="",o.value=!0;try{await b.login(l.value,r.value),_.push(g.query.redirect||"/")}catch(d){s.value=d.message}finally{o.value=!1}}return(d,e)=>{const w=C("router-link");return f(),p("div",R,[t("div",T,[t("div",U,[e[2]||(e[2]=t("div",{class:"logo"},"💬",-1)),t("h1",null,u(x(i).siteName),1),e[3]||(e[3]=t("p",null,"登录您的账户",-1))]),t("form",{onSubmit:N(h,["prevent"])},[t("div",j,[e[4]||(e[4]=t("label",null,"账号",-1)),v(t("input",{"onUpdate:modelValue":e[0]||(e[0]=n=>l.value=n),class:"form-input",placeholder:"用户名或邮箱",required:""},null,512),[[c,l.value]])]),t("div",A,[e[5]||(e[5]=t("label",null,"密码",-1)),v(t("input",{"onUpdate:modelValue":e[1]||(e[1]=n=>r.value=n),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[c,r.value]])]),s.value?(f(),p("p",E,u(s.value),1)):S("",!0),t("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:o.value},u(o.value?"登录中...":"登录"),9,I)],32),t("p",P,[e[7]||(e[7]=m(" 还没有账户? ",-1)),q(w,{to:"/register"},{default:B(()=>[...e[6]||(e[6]=[m("立即注册",-1)])]),_:1})])])])}}},J=y(z,[["__scopeId","data-v-a92a8550"]]);export{J as default};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.auth-page[data-v-09353b56]{position:relative;min-height:100dvh;display:grid;place-items:center;padding:28px;overflow:auto;background:radial-gradient(circle at 72% 16%,rgba(183,243,107,.08),transparent 26%),var(--bg-primary)}.auth-theme-toggle[data-v-09353b56]{position:absolute;top:18px;right:18px;z-index:3}.auth-shell[data-v-09353b56]{position:relative;display:grid;width:min(900px,100%);grid-template-columns:1.08fr .92fr;overflow:hidden;border:1px solid var(--border-strong);border-radius:22px;background:var(--bg-secondary);box-shadow:inset 0 1px #ffffff0b,var(--shadow)}.auth-shell[data-v-09353b56]:after{position:absolute;top:0;left:16%;width:36%;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);box-shadow:0 0 17px #b7f36b70;content:""}.auth-story[data-v-09353b56]{position:relative;display:flex;min-height:610px;flex-direction:column;justify-content:center;padding:54px;overflow:hidden;border-right:1px solid var(--border);background:linear-gradient(135deg,rgba(183,243,107,.07),transparent 45%),repeating-linear-gradient(135deg,rgba(255,255,255,.018) 0 1px,transparent 1px 14px),#0c0f13}.auth-logo[data-v-09353b56]{display:grid;width:46px;height:46px;place-items:center;margin-bottom:46px;border:1px solid rgba(183,243,107,.5);border-radius:14px;background:var(--accent);color:#11150e;box-shadow:inset 0 1px #ffffff85,0 5px #55782f,0 14px 28px #6ea6362b}.auth-eyebrow[data-v-09353b56],.auth-note[data-v-09353b56]{color:var(--accent);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.16em}.auth-story h1[data-v-09353b56]{margin:15px 0 19px;font-size:clamp(36px,4.7vw,54px);font-weight:760;letter-spacing:-.06em;line-height:1.04;text-wrap:balance}.auth-story p[data-v-09353b56]{max-width:31ch;color:var(--text-secondary);font-size:14px;line-height:1.75}.auth-note[data-v-09353b56]{position:absolute;bottom:30px;left:54px;color:var(--text-muted);font-size:9px}.auth-card[data-v-09353b56]{display:flex;flex-direction:column;justify-content:center;padding:42px;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.05),transparent 28%),var(--bg-secondary)}.auth-header[data-v-09353b56]{margin-bottom:27px}.auth-status[data-v-09353b56]{display:inline-flex;align-items:center;gap:7px;color:var(--text-muted);font-size:11px;font-weight:650;letter-spacing:.06em}.auth-status i[data-v-09353b56]{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #b7f36b99}.auth-header h2[data-v-09353b56]{margin:12px 0 7px;font-size:27px;letter-spacing:-.045em}.auth-header p[data-v-09353b56],.auth-footer[data-v-09353b56]{color:var(--text-secondary);font-size:13px}.auth-btn[data-v-09353b56]{width:100%;min-height:47px;margin-top:6px}.auth-footer[data-v-09353b56]{margin-top:23px;text-align:center}@media(max-width:720px){.auth-page[data-v-09353b56]{padding:14px}.auth-shell[data-v-09353b56]{grid-template-columns:1fr}.auth-story[data-v-09353b56]{display:none}.auth-card[data-v-09353b56]{min-height:640px;padding:36px 25px}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
.auth-page[data-v-ab6798e7]{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--bg-secondary)}.auth-card[data-v-ab6798e7]{width:100%;max-width:400px;padding:40px 32px;background:var(--bg-primary);border-radius:16px;border:1px solid var(--border);box-shadow:var(--shadow)}.auth-header[data-v-ab6798e7]{text-align:center;margin-bottom:32px}.logo[data-v-ab6798e7]{font-size:48px;margin-bottom:12px}.auth-header h1[data-v-ab6798e7]{font-size:24px;margin-bottom:8px}.auth-header p[data-v-ab6798e7]{color:var(--text-secondary);font-size:14px}.auth-btn[data-v-ab6798e7]{width:100%;margin-top:8px;padding:14px}.auth-footer[data-v-ab6798e7]{text-align:center;margin-top:24px;font-size:14px;color:var(--text-secondary)}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{_ as h,u as V,o as x,c as f,a as t,t as d,b as k,w as N,d as p,v as m,e as R,f as c,g as S,h as q,r as l,i as B,j as C,l as g}from"./index-k46zOoYG.js";import{u as M}from"./settings-1-hPceiv.js";const U={class:"auth-page"},D={class:"auth-card"},T={class:"auth-header"},j={class:"form-group"},A={class:"form-group"},E={class:"form-group"},I={key:0,class:"form-error"},P=["disabled"],z={class:"auth-footer"},F={__name:"RegisterView",setup(G){const _=C(),b=V(),r=M(),u=l(""),n=l(""),i=l(""),s=l(""),a=l(!1);x(()=>r.loadPublic());async function w(){if(!r.allowRegister){s.value="当前不允许注册";return}s.value="",a.value=!0;try{await b.register(u.value,n.value,i.value),_.push("/")}catch(v){s.value=v.message}finally{a.value=!1}}return(v,e)=>{const y=B("router-link");return g(),f("div",U,[t("div",D,[t("div",T,[e[3]||(e[3]=t("div",{class:"logo"},"💬",-1)),t("h1",null,d(k(r).siteName),1),e[4]||(e[4]=t("p",null,"创建新账户",-1))]),t("form",{onSubmit:N(w,["prevent"])},[t("div",j,[e[5]||(e[5]=t("label",null,"用户名",-1)),p(t("input",{"onUpdate:modelValue":e[0]||(e[0]=o=>u.value=o),class:"form-input",placeholder:"3-50 个字符",required:""},null,512),[[m,u.value]])]),t("div",A,[e[6]||(e[6]=t("label",null,"邮箱",-1)),p(t("input",{"onUpdate:modelValue":e[1]||(e[1]=o=>n.value=o),type:"email",class:"form-input",placeholder:"your@email.com",required:""},null,512),[[m,n.value]])]),t("div",E,[e[7]||(e[7]=t("label",null,"密码",-1)),p(t("input",{"onUpdate:modelValue":e[2]||(e[2]=o=>i.value=o),type:"password",class:"form-input",placeholder:"至少 6 位",required:""},null,512),[[m,i.value]])]),s.value?(g(),f("p",I,d(s.value),1)):R("",!0),t("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:a.value},d(a.value?"注册中...":"注册"),9,P)],32),t("p",z,[e[9]||(e[9]=c(" 已有账户? ",-1)),S(y,{to:"/login"},{default:q(()=>[...e[8]||(e[8]=[c("立即登录",-1)])]),_:1})])])])}}},K=h(F,[["__scopeId","data-v-ab6798e7"]]);export{K as default};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{_ as V,u as k,o as N,c as b,a as p,b as e,d as h,e as l,t as m,w as R,f as v,v as f,g as S,h as x,r as a,i as A,j as E,l as c}from"./index-sdqi2xzF.js";import{u as I,I as C,T as M}from"./settings-R2Yjxl-Z.js";const q={class:"auth-page"},B={class:"auth-shell"},G={class:"auth-story"},U={class:"auth-logo"},D={class:"auth-card"},j={class:"auth-header"},z={class:"form-group"},P={class:"form-group"},X={class:"form-group"},F={key:0,class:"form-error"},H=["disabled"],J={class:"auth-footer"},K={__name:"RegisterView",setup(L){const _=E(),w=k(),n=I(),r=a(""),i=a(""),d=a(""),t=a(""),u=a(!1);N(()=>n.loadPublic());async function y(){if(!n.allowRegister){t.value="当前不允许注册";return}t.value="",u.value=!0;try{await w.register(r.value,i.value,d.value),_.push("/")}catch(g){t.value=g.message}finally{u.value=!1}}return(g,s)=>{const T=A("router-link");return c(),b("div",q,[p(M,{class:"auth-theme-toggle"}),e("section",B,[e("aside",G,[e("span",U,[p(h(C),{size:22,"stroke-width":1.8})]),s[3]||(s[3]=e("span",{class:"auth-eyebrow"},"START CREATING",-1)),s[4]||(s[4]=e("h1",null,[l("一个账户,"),e("br"),l("连接全部创作工具。")],-1)),s[5]||(s[5]=e("p",null,"创建账户后即可保存会话,并使用可用的模型与 Agent。",-1)),s[6]||(s[6]=e("span",{class:"auth-note"},"TEXT · IMAGE · AGENT",-1))]),e("div",D,[e("div",j,[s[7]||(s[7]=e("span",{class:"auth-status"},[e("i"),l(" 创建账户")],-1)),e("h2",null,m(h(n).siteName),1),s[8]||(s[8]=e("p",null,"填写信息,开始新的创作会话",-1))]),e("form",{onSubmit:R(y,["prevent"])},[e("div",z,[s[9]||(s[9]=e("label",null,"用户名",-1)),v(e("input",{"onUpdate:modelValue":s[0]||(s[0]=o=>r.value=o),class:"form-input",placeholder:"3-50 个字符",required:""},null,512),[[f,r.value]])]),e("div",P,[s[10]||(s[10]=e("label",null,"邮箱",-1)),v(e("input",{"onUpdate:modelValue":s[1]||(s[1]=o=>i.value=o),type:"email",class:"form-input",placeholder:"your@email.com",required:""},null,512),[[f,i.value]])]),e("div",X,[s[11]||(s[11]=e("label",null,"密码",-1)),v(e("input",{"onUpdate:modelValue":s[2]||(s[2]=o=>d.value=o),type:"password",class:"form-input",placeholder:"至少 6 位",required:""},null,512),[[f,d.value]])]),t.value?(c(),b("p",F,m(t.value),1)):S("",!0),e("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:u.value},m(u.value?"注册中...":"创建并进入"),9,H)],32),e("p",J,[s[13]||(s[13]=l(" 已有账户? ",-1)),p(T,{to:"/login"},{default:x(()=>[...s[12]||(s[12]=[l("立即登录",-1)])]),_:1})])])])])}}},W=V(K,[["__scopeId","data-v-09353b56"]]);export{W as default};
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.notification-region[data-v-a3b13931]{position:fixed;top:16px;right:16px;z-index:120;display:flex;width:min(420px,calc(100vw - 32px));flex-direction:column;gap:10px;pointer-events:none}.notification-card[data-v-a3b13931]{position:relative;display:grid;grid-template-columns:38px minmax(0,1fr);gap:12px;overflow:hidden;padding:14px;background:#fffffffa;border:1px solid var(--border-strong);border-radius:16px;box-shadow:0 18px 48px #263b5c29;pointer-events:auto}.notification-card[data-v-a3b13931]:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--danger)}.notification-icon[data-v-a3b13931]{width:38px;height:38px;border-radius:12px;display:flex;align-items:center;justify-content:center;background:#fff0f0;color:var(--danger)}.notification-content[data-v-a3b13931]{min-width:0}.notification-heading[data-v-a3b13931]{display:flex;min-height:28px;align-items:flex-start;justify-content:space-between;gap:10px}.notification-heading strong[data-v-a3b13931]{padding-top:2px;color:var(--text-primary);font-size:15px;font-weight:650;line-height:1.4}.notification-close[data-v-a3b13931]{width:28px;height:28px;margin:-3px -4px 0 0;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text-muted);flex-shrink:0;transition:background .16s ease,color .16s ease,transform .16s ease}.notification-close[data-v-a3b13931]:hover{background:var(--bg-soft);color:var(--text-primary)}.notification-close[data-v-a3b13931]:active,.copy-detail[data-v-a3b13931]:active{transform:scale(.96)}.notification-content>p[data-v-a3b13931]{max-width:36em;color:var(--text-secondary);font-size:13px;line-height:1.65;text-wrap:pretty}.notification-detail[data-v-a3b13931]{margin-top:10px;border-top:1px solid var(--border)}.notification-detail summary[data-v-a3b13931]{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 0 1px;color:var(--text-secondary);font-size:12px;font-weight:550;cursor:pointer;list-style:none;-webkit-user-select:none;user-select:none}.notification-detail summary[data-v-a3b13931]::-webkit-details-marker{display:none}.detail-chevron[data-v-a3b13931]{transition:transform .18s ease}.notification-detail[open] .detail-chevron[data-v-a3b13931]{transform:rotate(180deg)}.detail-body[data-v-a3b13931]{margin-top:8px;padding:10px;background:#f6f8fb;border:1px solid var(--border);border-radius:10px}.detail-body pre[data-v-a3b13931]{max-height:190px;overflow:auto;color:#354258;font-family:SFMono-Regular,Consolas,Liberation Mono,monospace;font-size:11px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.copy-detail[data-v-a3b13931]{display:inline-flex;align-items:center;gap:5px;min-height:30px;margin-top:9px;padding:0 9px;background:#fff;border:1px solid var(--border-strong);border-radius:8px;color:var(--text-secondary);font-size:11px;transition:background .16s ease,color .16s ease,transform .16s ease}.copy-detail[data-v-a3b13931]:hover{background:var(--bg-soft);color:var(--text-primary)}.notification-enter-active[data-v-a3b13931],.notification-leave-active[data-v-a3b13931]{transition:opacity .22s ease,transform .22s cubic-bezier(.16,1,.3,1)}.notification-enter-from[data-v-a3b13931],.notification-leave-to[data-v-a3b13931]{opacity:0;transform:translateY(-8px) scale(.98)}@media(max-width:768px){.notification-region[data-v-a3b13931]{top:calc(10px + env(safe-area-inset-top));right:10px;left:10px;width:auto}.notification-card[data-v-a3b13931]{grid-template-columns:34px minmax(0,1fr);gap:10px;padding:12px;border-radius:14px}.notification-icon[data-v-a3b13931]{width:34px;height:34px;border-radius:10px}.detail-body pre[data-v-a3b13931]{max-height:min(220px,36dvh)}}@media(prefers-reduced-motion:reduce){.notification-enter-active[data-v-a3b13931],.notification-leave-active[data-v-a3b13931],.notification-close[data-v-a3b13931],.copy-detail[data-v-a3b13931],.detail-chevron[data-v-a3b13931]{transition:none}}pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
|
||||||
Theme: GitHub
|
|
||||||
Description: Light theme as seen on github.com
|
|
||||||
Author: github.com
|
|
||||||
Maintainer: @Hirse
|
|
||||||
Updated: 2021-05-15
|
|
||||||
|
|
||||||
Outdated base version: https://github.com/primer/github-syntax-light
|
|
||||||
Current colors taken from GitHub's CSS
|
|
||||||
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}:root{--bg-primary: #f5f7fb;--bg-secondary: #ffffff;--bg-tertiary: #f8fafc;--bg-hover: #eef3f9;--bg-soft: #f2f5f8;--text-primary: #152033;--text-secondary: #526077;--text-muted: #7d899b;--accent: #2d66da;--accent-hover: #2458c4;--accent-soft: #edf4ff;--border: #e1e7ef;--border-strong: #d4dce7;--danger: #dc4c4c;--sidebar-width: 268px;--header-height: 66px;--input-max-width: 920px;--content-max-width: 1040px;--radius: 18px;--radius-sm: 12px;--shadow: 0 18px 50px rgba(38, 59, 92, .08);--shadow-soft: 0 8px 24px rgba(38, 59, 92, .06);--shadow-composer: 0 14px 38px rgba(38, 59, 92, .09)}*{margin:0;padding:0;box-sizing:border-box}html,body,#app{height:100%;min-height:100dvh;font-family:Microsoft YaHei UI,PingFang SC,Segoe UI,sans-serif;background:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}body{overflow:hidden}a{color:var(--accent);text-decoration:none}button{cursor:pointer;border:none;background:none;color:inherit;font:inherit}button:focus-visible,a:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid rgba(45,102,218,.72);outline-offset:2px}input,textarea,select{font:inherit;color:inherit;background:transparent;border:none;outline:none}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#ced7e3;border-radius:999px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 20px;border-radius:999px;font-size:14px;font-weight:500;transition:color .16s ease,background .16s ease,border-color .16s ease,transform .16s ease}.btn:active{transform:scale(.97)}.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}.btn-primary:disabled{opacity:.5;cursor:not-allowed}.btn-ghost{background:#fff;color:var(--text-secondary);border:1px solid var(--border)}.btn-ghost:hover{background:var(--bg-hover);color:var(--text-primary)}.btn-danger{color:var(--danger)}.btn-danger:hover{background:#ef44441a}.form-group{margin-bottom:16px}.form-group label{display:block;margin-bottom:6px;font-size:14px;color:var(--text-secondary)}.form-input{width:100%;padding:12px 16px;background:#fff;border:1px solid var(--border);border-radius:14px;font-size:15px;transition:border-color .2s,box-shadow .2s}.form-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px #2d66da1c}.form-error{color:var(--danger);font-size:13px;margin-top:12px}.markdown-body{line-height:1.7;font-size:15px;word-break:break-word}.markdown-body p{margin-bottom:12px}.markdown-body p:last-child{margin-bottom:0}.markdown-body br{content:"";display:block;margin-top:10px}.markdown-body pre{background:#f8fafc;border:1px solid var(--border);border-radius:14px;padding:16px;overflow-x:auto;margin:12px 0}.markdown-body code{font-family:SF Mono,Monaco,Consolas,monospace;font-size:13px}.markdown-body :not(pre)>code{background:#eef2ff;padding:2px 6px;border-radius:6px}.markdown-body ul,.markdown-body ol{padding-left:24px;margin-bottom:12px}.markdown-body blockquote{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-secondary);margin:12px 0}.markdown-body table{border-collapse:collapse;width:100%;margin:12px 0}.markdown-body th,.markdown-body td{border:1px solid var(--border);padding:8px 12px;text-align:left}.markdown-body th{background:var(--bg-soft)}.markdown-body a{color:var(--accent)}.markdown-body img{max-width:100%;border-radius:8px}.sidebar-overlay{display:none;position:fixed;top:0;right:0;bottom:0;left:0;background:#15203357;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:90}@media(max-width:768px){.sidebar-overlay.active{display:block}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{m,r as e,n as s}from"./index-k46zOoYG.js";const v=m("settings",()=>{const o=e({markdown:!0,image:!0,video:!0,voice:!0,document:!0,emoji:!0,upload_image:!0,upload_video:!0,upload_file:!0,paste_image:!0}),n=e("AI Chat"),u=e(!0),r=e([]),i=e([]),l=e(!1);async function d(){const a=(await s.get("/settings/public")).data.data;o.value=a.features,n.value=a.site_name,u.value=a.allow_register,l.value=!0}async function c(){const t=await s.get("/models");r.value=t.data.data}async function g(){const t=await s.get("/agents");i.value=t.data.data||[]}return{features:o,siteName:n,allowRegister:u,models:r,agents:i,loaded:l,loadPublic:d,loadModels:c,loadAgents:g}});export{v as u};
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import{p as f,_ as k,l as b,c as h,b as u,d as s,y as g,P as y,m as T,r as t,n as c}from"./index-sdqi2xzF.js";/**
|
||||||
|
* @license @tabler/icons-vue v3.45.0 - MIT
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/var A=f("outline","sparkles","Sparkles",[["path",{d:"M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6",key:"svg-0"}]]);const w={class:"theme-toggle",role:"group","aria-label":"界面主题"},C=["aria-pressed"],I=["aria-pressed"],S={__name:"ThemeToggle",setup(d){const{theme:a,setTheme:o}=y();return(n,e)=>(b(),h("div",w,[u("button",{type:"button",class:g({active:s(a)==="light"}),"aria-pressed":s(a)==="light","aria-label":"使用浅色主题",title:"浅色主题",onClick:e[0]||(e[0]=r=>s(o)("light"))},[...e[2]||(e[2]=[u("span",null,"浅色",-1)])],10,C),u("button",{type:"button",class:g({active:s(a)==="dark"}),"aria-pressed":s(a)==="dark","aria-label":"使用深色主题",title:"深色主题",onClick:e[1]||(e[1]=r=>s(o)("dark"))},[...e[3]||(e[3]=[u("span",null,"深色",-1)])],10,I)]))}},B=k(S,[["__scopeId","data-v-6644b9d7"]]),P=T("settings",()=>{const d=t({markdown:!0,image:!0,video:!0,voice:!0,document:!0,emoji:!0,upload_image:!0,upload_video:!0,upload_file:!0,paste_image:!0}),a=t("AI Chat"),o=t(!0),n=t({name:"AI 客服",greeting:"您好,请问有什么可以帮您?"}),e=t([]),r=t([]),p=t(!1);async function m(){const i=(await c.get("/settings/public")).data.data;d.value=i.features,a.value=i.site_name,o.value=i.allow_register,n.value=i.voice_persona||n.value,p.value=!0}async function v(){const l=await c.get("/models");e.value=l.data.data}async function _(){const l=await c.get("/agents");r.value=l.data.data||[]}return{features:d,siteName:a,allowRegister:o,voicePersona:n,models:e,agents:r,loaded:p,loadPublic:m,loadModels:v,loadAgents:_}});export{A as I,B as T,P as u};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.theme-toggle[data-v-6644b9d7]{display:inline-grid;grid-template-columns:repeat(2,42px);gap:0;padding:2px;border:1px solid var(--border);border-radius:6px;background:var(--bg-tertiary)}button[data-v-6644b9d7]{display:grid;width:42px;height:30px;place-items:center;border:1px solid transparent;border-radius:4px;color:var(--text-muted);font-size:11px;font-weight:600;transition:color .16s ease,background .16s ease,transform .16s ease}button[data-v-6644b9d7]:hover{color:var(--text-primary);background:var(--bg-hover)}button[data-v-6644b9d7]:active{transform:translateY(1px) scale(.96)}button.active[data-v-6644b9d7]{border-color:transparent;background:var(--button-primary-bg);color:var(--button-primary-text)}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>AI Chat</title>
|
<title>AI Chat</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<script type="module" crossorigin src="/assets/index-k46zOoYG.js"></script>
|
<script type="module" crossorigin src="/assets/index-sdqi2xzF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-3dTEmASf.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-De9xW-9E.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ Route::group('api', function () {
|
|||||||
Route::get('conversations/:id/messages', 'api.Conversation/messages');
|
Route::get('conversations/:id/messages', 'api.Conversation/messages');
|
||||||
|
|
||||||
Route::post('chat/completions', 'api.Chat/completions');
|
Route::post('chat/completions', 'api.Chat/completions');
|
||||||
|
Route::post('chat/speech', 'api.Chat/speech');
|
||||||
|
Route::post('chat/speech/stream', 'api.Chat/speechStream');
|
||||||
|
Route::post('chat/speech/cancel', 'api.Chat/speechCancel');
|
||||||
Route::post('upload', 'api.Upload/upload');
|
Route::post('upload', 'api.Upload/upload');
|
||||||
Route::get('models', 'api.Settings/models');
|
Route::get('models', 'api.Settings/models');
|
||||||
Route::get('agents', 'api.Settings/agents');
|
Route::get('agents', 'api.Settings/agents');
|
||||||
@@ -71,6 +74,8 @@ Route::group('api', function () {
|
|||||||
Route::get('stats', 'api.Admin/stats');
|
Route::get('stats', 'api.Admin/stats');
|
||||||
Route::get('settings', 'api.Admin/settings');
|
Route::get('settings', 'api.Admin/settings');
|
||||||
Route::put('settings', 'api.Admin/updateSettings');
|
Route::put('settings', 'api.Admin/updateSettings');
|
||||||
|
Route::post('voice/reference', 'api.Admin/uploadVoiceReference');
|
||||||
|
Route::post('voice/preview', 'api.Admin/previewVoicePersona');
|
||||||
Route::get('models', 'api.Admin/models');
|
Route::get('models', 'api.Admin/models');
|
||||||
Route::post('models/test', 'api.Admin/testModel');
|
Route::post('models/test', 'api.Admin/testModel');
|
||||||
Route::post('models/:id/test', 'api.Admin/testModel');
|
Route::post('models/:id/test', 'api.Admin/testModel');
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,86 @@
|
|||||||
|
# CosyVoice 客服语音部署
|
||||||
|
|
||||||
|
当前聊天应用已经内置 CosyVoice FastAPI 适配器。语音对话使用 SSE 持续转发 PCM16,浏览器收到第一段音频后立即播放;兼容接口仍可把 PCM16 封装为 WAV。CosyVoice 异常时会熔断 20 秒,并自动回落到 OpenAI/浏览器语音。
|
||||||
|
|
||||||
|
## 当前部署(2026-07-23)
|
||||||
|
|
||||||
|
- 服务地址:`http://192.168.110.111:50000`(仅绑定局域网地址)
|
||||||
|
- 容器:`cosyvoice3`,重启策略 `unless-stopped`
|
||||||
|
- 镜像:`local/cosyvoice3:cu128-stream4-cancel`
|
||||||
|
- 模型:`FunAudioLLM/Fun-CosyVoice3-0.5B-2512`
|
||||||
|
- GPU:第 4 张 NVIDIA RTX 6000D(Docker 设备编号 `3`)
|
||||||
|
- 服务目录:`/home/ps/services/cosyvoice3`
|
||||||
|
- 输出格式:24 kHz、单声道、PCM16;语音对话由 PHP 以 SSE 原样转发并通过 Web Audio 边收边播
|
||||||
|
- 打断:每次合成带唯一 `request_id`;浏览器插话后会停止已排期音频、终止文本请求,并调用 GPU 服务的 `/cancel/{request_id}` 停止后续生成
|
||||||
|
- 回滚容器:`cosyvoice3-stream2-rollback-20260723`(上一版流式服务)和 `cosyvoice3-rollback-20260723`(最初稳定版),均保持停止状态
|
||||||
|
|
||||||
|
本机端到端实测:SSE 响应头约 0.09 秒返回,短句第一段可播放音频约 2.1–2.6 秒到达。长文本收到第一段音频后执行取消,GPU 在约 0.8 秒内关闭生成流,未继续生成后续段落。数据库已启用会员端语音开关,并配置为 `zero_shot` 模式。当前使用官方仓库参考音频作为临时演示音色,正式上线前应在管理后台替换为已获得授权的真人客服 WAV 及其完全一致的逐字稿。
|
||||||
|
|
||||||
|
## 1. GPU 主机部署官方服务
|
||||||
|
|
||||||
|
建议使用 NVIDIA GPU 和 Linux/Docker。按照官方仓库构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recursive https://github.com/FunAudioLLM/CosyVoice.git
|
||||||
|
cd CosyVoice/runtime/python
|
||||||
|
docker build -t cosyvoice:v1.0 .
|
||||||
|
```
|
||||||
|
|
||||||
|
快速使用内置中文女声(SFT):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --gpus all --restart unless-stopped \
|
||||||
|
-p 50000:50000 cosyvoice:v1.0 \
|
||||||
|
/bin/bash -lc "cd /opt/CosyVoice/CosyVoice/runtime/python/fastapi && python3 server.py --port 50000 --model_dir iic/CosyVoice-300M-SFT"
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境请只允许聊天后端访问 50000 端口,或在反向代理中设置 Bearer Token;不要把官方无鉴权 FastAPI 直接暴露到公网。
|
||||||
|
|
||||||
|
## 2. 在管理后台配置 AI 客服人物
|
||||||
|
|
||||||
|
登录管理后台,进入「系统设置 → AI 客服人物」,填写 CosyVoice 服务地址,并选择人物模板、合成模式、说话人和采样率。点击「保存并试听音色」可以立即验证服务。
|
||||||
|
|
||||||
|
后台保存的设置优先级高于环境变量;环境变量用于首次启动时提供默认值:
|
||||||
|
|
||||||
|
在 `backend/.env` 中加入:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
COSYVOICE_ENABLED=true
|
||||||
|
COSYVOICE_BASE_URL=http://GPU服务器内网地址:50000
|
||||||
|
COSYVOICE_MODE=sft
|
||||||
|
COSYVOICE_SPEAKER=中文女
|
||||||
|
COSYVOICE_SAMPLE_RATE=22050
|
||||||
|
COSYVOICE_CONNECT_TIMEOUT_MS=800
|
||||||
|
COSYVOICE_TIMEOUT_SECONDS=8
|
||||||
|
```
|
||||||
|
|
||||||
|
只使用管理后台修改时无需重启 PHP 服务。
|
||||||
|
|
||||||
|
## 3. 最佳质量:CosyVoice 3 零样本客服音色
|
||||||
|
|
||||||
|
使用一段已获得说话人明确授权、干净无背景音乐的客服录音,并准备完全一致的逐字稿。不要克隆未授权的真人声音。
|
||||||
|
|
||||||
|
GPU 服务改用 `FunAudioLLM/Fun-CosyVoice3-0.5B-2512`。在管理后台将模式切换为 `Zero-shot 克隆音色`,上传 WAV 并填写与录音完全一致的逐字稿;音色文件会安全保存到 `backend/storage/cosyvoice/`。
|
||||||
|
|
||||||
|
也可以在首次启动前通过环境变量提供默认值:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
COSYVOICE_MODE=zero_shot
|
||||||
|
COSYVOICE_PROMPT_WAV=D:/web/chat/backend/storage/cosyvoice/customer-service.wav
|
||||||
|
COSYVOICE_PROMPT_TEXT=You are a helpful assistant.<|endofprompt|>这里填写参考音频的完整逐字稿。
|
||||||
|
COSYVOICE_SAMPLE_RATE=24000
|
||||||
|
```
|
||||||
|
|
||||||
|
音色样本由聊天后端通过 multipart 请求发送给 CosyVoice,因此 GPU 服务和 PHP 后端可以位于不同主机。
|
||||||
|
|
||||||
|
## 4. 情绪与语速控制
|
||||||
|
|
||||||
|
使用 Instruct 模型时:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
COSYVOICE_MODE=instruct
|
||||||
|
COSYVOICE_SPEAKER=中文女
|
||||||
|
COSYVOICE_INSTRUCT=请用温暖、自然、耐心的中文客服语气表达,语速适中,停顿真实,避免播音腔和夸张情绪。
|
||||||
|
```
|
||||||
|
|
||||||
|
如果服务返回错误或超时,页面会显示设备语音兜底状态,不会中断文字客服对话。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
FROM local/cosyvoice3:cu128-api4
|
||||||
|
COPY server_cosyvoice3.py /opt/CosyVoice/CosyVoice/runtime/python/fastapi/server_cosyvoice3.py
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, File, Form, UploadFile
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
logging.getLogger("matplotlib").setLevel(logging.WARNING)
|
||||||
|
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(os.path.join(ROOT_DIR, "../../.."))
|
||||||
|
sys.path.append(os.path.join(ROOT_DIR, "../../../third_party/Matcha-TTS"))
|
||||||
|
|
||||||
|
from cosyvoice.cli.cosyvoice import AutoModel
|
||||||
|
|
||||||
|
app = FastAPI(title="CosyVoice 3 streaming API")
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
cosyvoice = None
|
||||||
|
model_name = ""
|
||||||
|
fp16_enabled = False
|
||||||
|
speaker_cache_lock = threading.Lock()
|
||||||
|
cancel_events = {}
|
||||||
|
cancel_events_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def persist_upload(upload: UploadFile) -> str:
|
||||||
|
suffix = os.path.splitext(upload.filename or "")[1] or ".wav"
|
||||||
|
with tempfile.NamedTemporaryFile(prefix="cosyvoice-prompt-", suffix=suffix, delete=False) as target:
|
||||||
|
upload.file.seek(0)
|
||||||
|
shutil.copyfileobj(upload.file, target)
|
||||||
|
return target.name
|
||||||
|
|
||||||
|
|
||||||
|
def speaker_cache_id(prompt_text: str, prompt_wav: str) -> str:
|
||||||
|
digest = hashlib.sha256(prompt_text.encode("utf-8"))
|
||||||
|
with open(prompt_wav, "rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return "voice-" + digest.hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_cached_speaker(prompt_text: str, prompt_wav: str) -> str:
|
||||||
|
cache_id = speaker_cache_id(prompt_text, prompt_wav)
|
||||||
|
with speaker_cache_lock:
|
||||||
|
if cache_id not in cosyvoice.frontend.spk2info:
|
||||||
|
cosyvoice.add_zero_shot_spk(prompt_text, prompt_wav, cache_id)
|
||||||
|
logging.info("cached zero-shot speaker %s", cache_id)
|
||||||
|
return cache_id
|
||||||
|
|
||||||
|
|
||||||
|
def register_cancel_event(request_id: str):
|
||||||
|
request_id = request_id.strip()
|
||||||
|
if not request_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
event = threading.Event()
|
||||||
|
with cancel_events_lock:
|
||||||
|
previous = cancel_events.get(request_id)
|
||||||
|
if previous is not None:
|
||||||
|
previous.set()
|
||||||
|
cancel_events[request_id] = event
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def release_cancel_event(request_id: str, event):
|
||||||
|
if not request_id or event is None:
|
||||||
|
return
|
||||||
|
with cancel_events_lock:
|
||||||
|
if cancel_events.get(request_id) is event:
|
||||||
|
cancel_events.pop(request_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def pcm_stream(model_output, cleanup_path: str = "", request_id: str = "", cancel_event=None):
|
||||||
|
iterator = iter(model_output)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
item = next(iterator)
|
||||||
|
except StopIteration:
|
||||||
|
break
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
break
|
||||||
|
audio = item["tts_speech"].detach().cpu().numpy()
|
||||||
|
yield (audio * (2**15)).astype(np.int16).tobytes()
|
||||||
|
finally:
|
||||||
|
close = getattr(iterator, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
release_cancel_event(request_id, cancel_event)
|
||||||
|
if cleanup_path:
|
||||||
|
try:
|
||||||
|
os.remove(cleanup_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def response(model_output, cleanup_path: str = "", request_id: str = ""):
|
||||||
|
request_id = request_id.strip()[:128]
|
||||||
|
cancel_event = register_cancel_event(request_id)
|
||||||
|
return StreamingResponse(
|
||||||
|
pcm_stream(model_output, cleanup_path, request_id, cancel_event),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"X-Sample-Rate": str(cosyvoice.sample_rate),
|
||||||
|
"X-Audio-Format": "pcm_s16le",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Cache-Control": "no-store, no-transform",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"model": model_name,
|
||||||
|
"sample_rate": cosyvoice.sample_rate,
|
||||||
|
"streaming": True,
|
||||||
|
"fp16": fp16_enabled,
|
||||||
|
"cached_speakers": len(cosyvoice.frontend.spk2info),
|
||||||
|
"active_streams": len(cancel_events),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/cancel/{request_id}")
|
||||||
|
def cancel(request_id: str):
|
||||||
|
with cancel_events_lock:
|
||||||
|
event = cancel_events.get(request_id)
|
||||||
|
if event is not None:
|
||||||
|
event.set()
|
||||||
|
return {"cancelled": event is not None, "request_id": request_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/inference_sft")
|
||||||
|
@app.post("/inference_sft")
|
||||||
|
def inference_sft(tts_text: str = Form(), spk_id: str = Form(), request_id: str = Form("")):
|
||||||
|
return response(cosyvoice.inference_sft(tts_text, spk_id, stream=True), request_id=request_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/inference_zero_shot")
|
||||||
|
@app.post("/inference_zero_shot")
|
||||||
|
def inference_zero_shot(
|
||||||
|
tts_text: str = Form(),
|
||||||
|
prompt_text: str = Form(),
|
||||||
|
prompt_wav: UploadFile = File(),
|
||||||
|
request_id: str = Form(""),
|
||||||
|
):
|
||||||
|
prompt_path = persist_upload(prompt_wav)
|
||||||
|
cache_id = ensure_cached_speaker(prompt_text, prompt_path)
|
||||||
|
return response(
|
||||||
|
cosyvoice.inference_zero_shot(
|
||||||
|
tts_text,
|
||||||
|
prompt_text,
|
||||||
|
prompt_path,
|
||||||
|
zero_shot_spk_id=cache_id,
|
||||||
|
stream=True,
|
||||||
|
),
|
||||||
|
prompt_path,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/inference_cross_lingual")
|
||||||
|
@app.post("/inference_cross_lingual")
|
||||||
|
def inference_cross_lingual(
|
||||||
|
tts_text: str = Form(),
|
||||||
|
prompt_wav: UploadFile = File(),
|
||||||
|
request_id: str = Form(""),
|
||||||
|
):
|
||||||
|
prompt_path = persist_upload(prompt_wav)
|
||||||
|
return response(
|
||||||
|
cosyvoice.inference_cross_lingual(tts_text, prompt_path, stream=True),
|
||||||
|
prompt_path,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/inference_instruct")
|
||||||
|
@app.post("/inference_instruct")
|
||||||
|
def inference_instruct(
|
||||||
|
tts_text: str = Form(),
|
||||||
|
spk_id: str = Form(),
|
||||||
|
instruct_text: str = Form(),
|
||||||
|
request_id: str = Form(""),
|
||||||
|
):
|
||||||
|
return response(
|
||||||
|
cosyvoice.inference_instruct(tts_text, spk_id, instruct_text, stream=True),
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/inference_instruct2")
|
||||||
|
@app.post("/inference_instruct2")
|
||||||
|
def inference_instruct2(
|
||||||
|
tts_text: str = Form(),
|
||||||
|
instruct_text: str = Form(),
|
||||||
|
prompt_wav: UploadFile = File(),
|
||||||
|
request_id: str = Form(""),
|
||||||
|
):
|
||||||
|
prompt_path = persist_upload(prompt_wav)
|
||||||
|
return response(
|
||||||
|
cosyvoice.inference_instruct2(tts_text, instruct_text, prompt_path, stream=True),
|
||||||
|
prompt_path,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--port", type=int, default=50000)
|
||||||
|
parser.add_argument("--model_dir", type=str, default="FunAudioLLM/Fun-CosyVoice3-0.5B-2512")
|
||||||
|
parser.add_argument("--fp16", action="store_true", help="Run the PyTorch model in FP16 on CUDA")
|
||||||
|
args = parser.parse_args()
|
||||||
|
model_name = args.model_dir
|
||||||
|
fp16_enabled = bool(args.fp16)
|
||||||
|
cosyvoice = AutoModel(model_dir=args.model_dir, fp16=fp16_enabled)
|
||||||
|
|
||||||
|
default_prompt_wav = os.path.join(ROOT_DIR, "../../../asset/zero_shot_prompt.wav")
|
||||||
|
default_prompt_text = "You are a helpful assistant.<|endofprompt|>希望你以后能够做的比我还好呦。"
|
||||||
|
if os.path.isfile(default_prompt_wav):
|
||||||
|
ensure_cached_speaker(default_prompt_text, default_prompt_wav)
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=args.port)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="theme-toggle" role="group" aria-label="界面主题">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ active: theme === 'light' }"
|
||||||
|
:aria-pressed="theme === 'light'"
|
||||||
|
aria-label="使用浅色主题"
|
||||||
|
title="浅色主题"
|
||||||
|
@click="setTheme('light')"
|
||||||
|
>
|
||||||
|
<span>浅色</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ active: theme === 'dark' }"
|
||||||
|
:aria-pressed="theme === 'dark'"
|
||||||
|
aria-label="使用深色主题"
|
||||||
|
title="深色主题"
|
||||||
|
@click="setTheme('dark')"
|
||||||
|
>
|
||||||
|
<span>深色</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: repeat(2, 42px);
|
||||||
|
gap: 0;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 30px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--button-primary-bg);
|
||||||
|
color: var(--button-primary-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="ui-icon"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path :d="iconPath" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: 'file' },
|
||||||
|
size: { type: [Number, String], default: 18 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
dashboard: 'M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-12h6V4h-6v4Z',
|
||||||
|
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
|
||||||
|
departments: 'M3 21h18M5 21V8l7-4 7 4v13M9 11h1m4 0h1m-6 4h1m4 0h1m-4 6v-3h2v3',
|
||||||
|
roles: 'M12 3 4.5 6v5.5c0 4.7 3.2 8.1 7.5 9.5 4.3-1.4 7.5-4.8 7.5-9.5V6L12 3Zm-2.3 9.2 1.55 1.55 3.2-3.5',
|
||||||
|
conversations: 'M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v8ZM8 9h8M8 13h5',
|
||||||
|
memberships: 'm12 3 2.7 5.45 6.02.88-4.36 4.25 1.03 6L12 17.7 6.61 20.5l1.03-6L3.28 9.33l6.02-.88L12 3Z',
|
||||||
|
models: 'M9 4.5V2m6 2.5V2M4.5 9H2m20 0h-2.5M7 5h10a2 2 0 0 1 2 2v9a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V7a2 2 0 0 1 2-2Zm2 6v2m6-2v2m-6 3h6',
|
||||||
|
permissions: 'M15 7a5 5 0 1 1 1.2 3.25L22 16v3h-3v2h-3v-3l-3.2-3.2M7 7h.01',
|
||||||
|
settings: 'M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm7.4-3.5c0-.55-.06-1.08-.17-1.6l2.05-1.6-2-3.46-2.5 1a8.4 8.4 0 0 0-2.77-1.6L13.62 2h-4l-.4 2.74a8.4 8.4 0 0 0-2.77 1.6l-2.5-1-2 3.46L4 10.4A7.7 7.7 0 0 0 3.83 12c0 .55.06 1.08.17 1.6l-2.05 1.6 2 3.46 2.5-1a8.4 8.4 0 0 0 2.77 1.6l.4 2.74h4l.4-2.74a8.4 8.4 0 0 0 2.77-1.6l2.5 1 2-3.46-2.05-1.6c.11-.52.17-1.05.17-1.6Z',
|
||||||
|
external: 'M14 3h7v7m0-7-9 9M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6',
|
||||||
|
logout: 'M10 17l5-5-5-5m5 5H3m11-9h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5',
|
||||||
|
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||||
|
folder: 'M3 6h7l2 2h9v11H3V6Z',
|
||||||
|
bolt: 'm13 2-9 12h7l-1 8 9-12h-7l1-8Z',
|
||||||
|
file: 'M6 2h8l4 4v16H6V2Zm8 0v5h5M9 13h6m-6 4h6',
|
||||||
|
activity: 'M3 12h4l2.5-6 5 12 2.5-6H21',
|
||||||
|
messages: 'M5 4h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H9l-5 4v-4a2 2 0 0 1-1-2V6a2 2 0 0 1 2-2Z',
|
||||||
|
chart: 'M4 20V10m6 10V4m6 16v-7m5 7H2'
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliases = {
|
||||||
|
'/dashboard': 'dashboard',
|
||||||
|
'/users': 'users',
|
||||||
|
'/departments': 'departments',
|
||||||
|
'/roles': 'roles',
|
||||||
|
'/conversations': 'conversations',
|
||||||
|
'/memberships': 'memberships',
|
||||||
|
'/models': 'models',
|
||||||
|
'/permissions': 'permissions',
|
||||||
|
'/settings': 'settings',
|
||||||
|
dir: 'folder',
|
||||||
|
menu: 'file',
|
||||||
|
btn: 'bolt'
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconPath = computed(() => paths[aliases[props.name] || props.name] || paths.file)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ui-icon {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ai-chat-theme'
|
||||||
|
const DEFAULT_THEME = 'light'
|
||||||
|
|
||||||
|
function readStoredTheme() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) === 'dark' ? 'dark' : DEFAULT_THEME
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_THEME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = ref(readStoredTheme())
|
||||||
|
let transitionTimer
|
||||||
|
|
||||||
|
function applyTheme(value, animate = false) {
|
||||||
|
const nextTheme = value === 'dark' ? 'dark' : 'light'
|
||||||
|
const root = document.documentElement
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
root.classList.add('theme-transitioning')
|
||||||
|
window.clearTimeout(transitionTimer)
|
||||||
|
transitionTimer = window.setTimeout(() => root.classList.remove('theme-transitioning'), 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
root.dataset.theme = nextTheme
|
||||||
|
root.style.colorScheme = nextTheme
|
||||||
|
theme.value = nextTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeTheme() {
|
||||||
|
applyTheme(theme.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const isDark = computed(() => theme.value === 'dark')
|
||||||
|
|
||||||
|
function setTheme(value) {
|
||||||
|
const nextTheme = value === 'dark' ? 'dark' : 'light'
|
||||||
|
applyTheme(nextTheme, true)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, nextTheme)
|
||||||
|
} catch {
|
||||||
|
// Theme still works for the current session when storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { theme, isDark, setTheme }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('storage', event => {
|
||||||
|
if (event.key === STORAGE_KEY) applyTheme(event.newValue)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export const permissionTree = [
|
|||||||
name: '数据概览',
|
name: '数据概览',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
icon: '📊',
|
icon: 'dashboard',
|
||||||
children: []
|
children: []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -25,7 +25,7 @@ export const permissionTree = [
|
|||||||
name: '用户管理',
|
name: '用户管理',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/users',
|
path: '/users',
|
||||||
icon: '👥',
|
icon: 'users',
|
||||||
children: [
|
children: [
|
||||||
{ code: 'btn:user:create', name: '新增用户', type: 'btn' },
|
{ code: 'btn:user:create', name: '新增用户', type: 'btn' },
|
||||||
{ code: 'btn:user:edit', name: '编辑用户', type: 'btn' },
|
{ code: 'btn:user:edit', name: '编辑用户', type: 'btn' },
|
||||||
@@ -38,7 +38,7 @@ export const permissionTree = [
|
|||||||
name: '部门管理',
|
name: '部门管理',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/departments',
|
path: '/departments',
|
||||||
icon: '🏢',
|
icon: 'departments',
|
||||||
children: [
|
children: [
|
||||||
{ code: 'btn:dept:create', name: '新增部门', type: 'btn' },
|
{ code: 'btn:dept:create', name: '新增部门', type: 'btn' },
|
||||||
{ code: 'btn:dept:edit', name: '编辑部门', type: 'btn' },
|
{ code: 'btn:dept:edit', name: '编辑部门', type: 'btn' },
|
||||||
@@ -50,7 +50,7 @@ export const permissionTree = [
|
|||||||
name: '角色管理',
|
name: '角色管理',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/roles',
|
path: '/roles',
|
||||||
icon: '🛡️',
|
icon: 'roles',
|
||||||
children: [
|
children: [
|
||||||
{ code: 'btn:role:create', name: '新增角色', type: 'btn' },
|
{ code: 'btn:role:create', name: '新增角色', type: 'btn' },
|
||||||
{ code: 'btn:role:edit', name: '编辑角色', type: 'btn' },
|
{ code: 'btn:role:edit', name: '编辑角色', type: 'btn' },
|
||||||
@@ -69,7 +69,7 @@ export const permissionTree = [
|
|||||||
name: '会话管理',
|
name: '会话管理',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/conversations',
|
path: '/conversations',
|
||||||
icon: '💬',
|
icon: 'conversations',
|
||||||
children: [
|
children: [
|
||||||
{ code: 'btn:conv:view_all', name: '查看全部会话', type: 'btn' },
|
{ code: 'btn:conv:view_all', name: '查看全部会话', type: 'btn' },
|
||||||
{ code: 'btn:conv:view_subordinate', name: '查看下级部门会话', type: 'btn' }
|
{ code: 'btn:conv:view_subordinate', name: '查看下级部门会话', type: 'btn' }
|
||||||
@@ -80,7 +80,7 @@ export const permissionTree = [
|
|||||||
name: '会员等级',
|
name: '会员等级',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/memberships',
|
path: '/memberships',
|
||||||
icon: '⭐',
|
icon: 'memberships',
|
||||||
children: [
|
children: [
|
||||||
{ code: 'btn:membership:create', name: '新增会员等级', type: 'btn' },
|
{ code: 'btn:membership:create', name: '新增会员等级', type: 'btn' },
|
||||||
{ code: 'btn:membership:edit', name: '编辑会员等级', type: 'btn' },
|
{ code: 'btn:membership:edit', name: '编辑会员等级', type: 'btn' },
|
||||||
@@ -99,7 +99,7 @@ export const permissionTree = [
|
|||||||
name: 'AI 模型',
|
name: 'AI 模型',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/models',
|
path: '/models',
|
||||||
icon: '🤖',
|
icon: 'models',
|
||||||
children: [
|
children: [
|
||||||
{ code: 'btn:model:create', name: '新增模型', type: 'btn' },
|
{ code: 'btn:model:create', name: '新增模型', type: 'btn' },
|
||||||
{ code: 'btn:model:edit', name: '编辑模型', type: 'btn' },
|
{ code: 'btn:model:edit', name: '编辑模型', type: 'btn' },
|
||||||
@@ -112,7 +112,7 @@ export const permissionTree = [
|
|||||||
name: '权限管理',
|
name: '权限管理',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/permissions',
|
path: '/permissions',
|
||||||
icon: '🔑',
|
icon: 'permissions',
|
||||||
children: [
|
children: [
|
||||||
{ code: 'btn:perm:create', name: '新增权限', type: 'btn' },
|
{ code: 'btn:perm:create', name: '新增权限', type: 'btn' },
|
||||||
{ code: 'btn:perm:edit', name: '编辑权限', type: 'btn' },
|
{ code: 'btn:perm:edit', name: '编辑权限', type: 'btn' },
|
||||||
@@ -124,7 +124,7 @@ export const permissionTree = [
|
|||||||
name: '系统设置',
|
name: '系统设置',
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
icon: '🔧',
|
icon: 'settings',
|
||||||
children: [
|
children: [
|
||||||
{ code: 'btn:settings:save', name: '保存设置', type: 'btn' }
|
{ code: 'btn:settings:save', name: '保存设置', type: 'btn' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
<div class="admin-layout">
|
<div class="admin-layout">
|
||||||
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<span class="brand-icon">⚙️</span>
|
<span class="brand-icon"><UiIcon name="bolt" :size="18" /></span>
|
||||||
<span>AI Chat 管理</span>
|
<span class="brand-copy">
|
||||||
|
<strong>AI Chat</strong>
|
||||||
|
<small>CONTROL PANEL</small>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a :href="memberUrl" target="_blank" class="sidebar-member-link">← 会员聊天端</a>
|
<a :href="memberUrl" target="_blank" class="sidebar-member-link">
|
||||||
|
<UiIcon name="external" :size="15" />
|
||||||
|
<span>打开会员聊天端</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav" aria-label="管理后台导航">
|
||||||
<template v-for="group in navGroups" :key="group.dir">
|
<template v-for="group in navGroups" :key="group.dir">
|
||||||
<div v-if="group.dirName" class="nav-group-title">{{ group.dirName }}</div>
|
<div v-if="group.dirName" class="nav-group-title">{{ group.dirName }}</div>
|
||||||
<router-link
|
<router-link
|
||||||
@@ -17,18 +23,26 @@
|
|||||||
class="nav-item"
|
class="nav-item"
|
||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
>
|
>
|
||||||
<span class="nav-icon">{{ item.icon }}</span>
|
<span class="nav-icon"><UiIcon :name="item.path" :size="17" /></span>
|
||||||
{{ item.label }}
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
|
<span class="nav-pip" aria-hidden="true" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
<ThemeToggle class="sidebar-theme-toggle" />
|
||||||
<div class="admin-user">
|
<div class="admin-user">
|
||||||
<span class="avatar">{{ avatarLetter }}</span>
|
<span class="avatar">{{ avatarLetter }}</span>
|
||||||
<span>{{ auth.user?.nickname || auth.user?.username }}</span>
|
<span class="admin-user-copy">
|
||||||
|
<strong>{{ auth.user?.nickname || auth.user?.username }}</strong>
|
||||||
|
<small>管理员账户</small>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost logout-btn" @click="handleLogout">退出登录</button>
|
<button class="btn btn-ghost logout-btn" type="button" @click="handleLogout">
|
||||||
|
<UiIcon name="logout" :size="15" />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -36,9 +50,15 @@
|
|||||||
|
|
||||||
<div class="main-area">
|
<div class="main-area">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button class="menu-btn" @click="sidebarOpen = true">☰</button>
|
<button class="menu-btn" type="button" aria-label="打开导航" @click="sidebarOpen = true">
|
||||||
|
<UiIcon name="menu" :size="20" />
|
||||||
|
</button>
|
||||||
<span class="page-title">{{ currentTitle }}</span>
|
<span class="page-title">{{ currentTitle }}</span>
|
||||||
<a :href="memberUrl" target="_blank" class="member-link">会员端</a>
|
<ThemeToggle />
|
||||||
|
<a :href="memberUrl" target="_blank" class="member-link">
|
||||||
|
<UiIcon name="external" :size="14" />
|
||||||
|
会员端
|
||||||
|
</a>
|
||||||
</header>
|
</header>
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<router-view />
|
<router-view />
|
||||||
@@ -51,6 +71,8 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import UiIcon from '@/components/UiIcon.vue'
|
||||||
|
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -69,7 +91,7 @@ const navGroups = computed(() => {
|
|||||||
group.items.push({
|
group.items.push({
|
||||||
path: menu.path,
|
path: menu.path,
|
||||||
label: menu.name,
|
label: menu.name,
|
||||||
icon: menu.icon || '📄'
|
icon: menu.icon || 'file'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
@@ -93,161 +115,284 @@ function handleLogout() {
|
|||||||
.admin-layout {
|
.admin-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
position: relative;
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex: 0 0 var(--sidebar-width);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
overflow: hidden;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 45% -10%, rgba(183, 243, 107, 0.08), transparent 25%),
|
||||||
|
rgba(13, 16, 21, 0.96);
|
||||||
|
box-shadow: 18px 0 50px rgba(0, 0, 0, 0.16);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -1px;
|
||||||
|
width: 1px;
|
||||||
|
height: 24%;
|
||||||
|
background: linear-gradient(to bottom, var(--accent), transparent);
|
||||||
|
box-shadow: 0 0 16px rgba(183, 243, 107, 0.42);
|
||||||
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 11px;
|
||||||
padding: 20px 16px;
|
padding: 20px 18px 18px;
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-icon {
|
.brand-icon {
|
||||||
font-size: 20px;
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(183, 243, 107, 0.42);
|
||||||
|
border-radius: 11px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #12170f;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 4px 0 #55782f, 0 10px 24px rgba(110, 166, 54, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy strong {
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-member-link {
|
.sidebar-member-link {
|
||||||
display: block;
|
display: flex;
|
||||||
margin: 0 12px 8px;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
margin: 0 12px 10px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
font-size: 12px;
|
||||||
border-radius: 8px;
|
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-member-link:hover {
|
.sidebar-member-link:hover {
|
||||||
|
border-color: rgba(183, 243, 107, 0.24);
|
||||||
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--bg-hover);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px 8px;
|
padding: 4px 10px 14px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group-title {
|
.nav-group-title {
|
||||||
padding: 12px 12px 6px;
|
padding: 15px 10px 7px;
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.04em;
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 42px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
margin-bottom: 3px;
|
||||||
border-radius: 8px;
|
padding: 8px 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
margin-bottom: 2px;
|
transition: transform 180ms var(--ease-spring), border-color 160ms ease, background 160ms ease, color 160ms ease;
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: var(--bg-hover);
|
border-color: var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.router-link-active {
|
.nav-item.router-link-active {
|
||||||
background: var(--accent);
|
border-color: rgba(183, 243, 107, 0.28);
|
||||||
color: white;
|
background: linear-gradient(90deg, rgba(183, 243, 107, 0.14), rgba(183, 243, 107, 0.045));
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
font-size: 16px;
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active .nav-icon {
|
||||||
|
background: rgba(183, 243, 107, 0.12);
|
||||||
|
box-shadow: 0 0 18px rgba(183, 243, 107, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pip {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0;
|
||||||
|
box-shadow: 0 0 9px currentColor;
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active .nav-pip {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 12px;
|
padding: 13px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-theme-toggle {
|
||||||
|
margin: 0 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user {
|
.admin-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 8px;
|
margin-bottom: 10px;
|
||||||
font-size: 13px;
|
padding: 6px 4px;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 28px;
|
display: grid;
|
||||||
height: 28px;
|
width: 34px;
|
||||||
border-radius: 50%;
|
height: 34px;
|
||||||
background: var(--accent);
|
flex: 0 0 auto;
|
||||||
display: flex;
|
place-items: center;
|
||||||
align-items: center;
|
border: 1px solid rgba(183, 243, 107, 0.3);
|
||||||
justify-content: center;
|
border-radius: 10px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-copy strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-copy small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-area {
|
.main-area {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
min-height: var(--header-height);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
height: var(--header-height);
|
padding: 0 14px;
|
||||||
padding: 0 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg-secondary);
|
background: rgba(12, 15, 20, 0.88);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-btn {
|
.menu-btn {
|
||||||
padding: 8px;
|
display: grid;
|
||||||
font-size: 18px;
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-link {
|
.member-link {
|
||||||
font-size: 13px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
font-size: 12px;
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.member-link:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 24px;
|
padding: clamp(24px, 3vw, 42px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content > :deep(*) {
|
||||||
|
max-width: 1480px;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-overlay {
|
.sidebar-overlay {
|
||||||
@@ -257,28 +402,31 @@ function handleLogout() {
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0 auto 0 0;
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
transition: transform 0.3s;
|
transition: transform 280ms var(--ease-spring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.open {
|
.sidebar.open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-overlay.active {
|
.sidebar-overlay.active {
|
||||||
display: block;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
|
display: block;
|
||||||
|
background: rgba(3, 4, 6, 0.72);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 16px;
|
padding: 20px 14px 28px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { initializeTheme } from './composables/useTheme'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
|
initializeTheme()
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|||||||
@@ -73,7 +73,9 @@
|
|||||||
class="message"
|
class="message"
|
||||||
:class="msg.role"
|
:class="msg.role"
|
||||||
>
|
>
|
||||||
<div class="message-avatar">{{ msg.role === 'user' ? '用户' : 'AI' }}</div>
|
<div class="message-avatar">
|
||||||
|
<UiIcon :name="msg.role === 'user' ? 'users' : 'models'" :size="16" />
|
||||||
|
</div>
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<div v-if="getAttachments(msg).length" class="attachments">
|
<div v-if="getAttachments(msg).length" class="attachments">
|
||||||
<template v-for="(att, i) in getAttachments(msg)" :key="i">
|
<template v-for="(att, i) in getAttachments(msg)" :key="i">
|
||||||
@@ -91,7 +93,8 @@
|
|||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="att-link"
|
class="att-link"
|
||||||
>
|
>
|
||||||
{{ documentIcon(att) }} {{ att.name || '附件' }}
|
<UiIcon name="file" :size="16" />
|
||||||
|
{{ att.name || '附件' }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +119,7 @@
|
|||||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import UiIcon from '@/components/UiIcon.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -257,14 +261,6 @@ function getAttachments(msg) {
|
|||||||
return list.filter(att => att && typeof att === 'object')
|
return list.filter(att => att && typeof att === 'object')
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentIcon(att) {
|
|
||||||
const name = (att?.name || '').toLowerCase()
|
|
||||||
const mime = att?.mime || ''
|
|
||||||
if (name.endsWith('.pdf') || mime.includes('pdf')) return '📕'
|
|
||||||
if (name.endsWith('.doc') || name.endsWith('.docx') || mime.includes('word')) return '📘'
|
|
||||||
return '📄'
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewImage(url) {
|
function previewImage(url) {
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
@@ -340,8 +336,8 @@ function formatDate(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conv-item.active {
|
.conv-item.active {
|
||||||
background: rgba(99, 102, 241, 0.15);
|
background: var(--accent-soft);
|
||||||
border: 1px solid rgba(99, 102, 241, 0.35);
|
border: 1px solid rgba(183, 243, 107, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conv-item-title {
|
.conv-item-title {
|
||||||
@@ -459,8 +455,8 @@ function formatDate(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.user .message-content {
|
.message.user .message-content {
|
||||||
background: rgba(99, 102, 241, 0.2);
|
background: var(--accent-soft);
|
||||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
border: 1px solid rgba(183, 243, 107, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-time {
|
.message-time {
|
||||||
|
|||||||
@@ -6,26 +6,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card">
|
<article class="stat-card">
|
||||||
<span class="stat-icon">👥</span>
|
<span class="stat-icon"><UiIcon name="users" :size="21" /></span>
|
||||||
<span class="stat-value">{{ stats.users }}</span>
|
<span class="stat-value">{{ stats.users }}</span>
|
||||||
<span class="stat-label">用户总数</span>
|
<span class="stat-label">用户总数</span>
|
||||||
</div>
|
<span class="stat-index">01</span>
|
||||||
<div class="stat-card">
|
</article>
|
||||||
<span class="stat-icon">💬</span>
|
<article class="stat-card">
|
||||||
|
<span class="stat-icon"><UiIcon name="conversations" :size="21" /></span>
|
||||||
<span class="stat-value">{{ stats.conversations }}</span>
|
<span class="stat-value">{{ stats.conversations }}</span>
|
||||||
<span class="stat-label">会话总数</span>
|
<span class="stat-label">会话总数</span>
|
||||||
</div>
|
<span class="stat-index">02</span>
|
||||||
<div class="stat-card">
|
</article>
|
||||||
<span class="stat-icon">📝</span>
|
<article class="stat-card">
|
||||||
|
<span class="stat-icon"><UiIcon name="messages" :size="21" /></span>
|
||||||
<span class="stat-value">{{ stats.messages }}</span>
|
<span class="stat-value">{{ stats.messages }}</span>
|
||||||
<span class="stat-label">消息总数</span>
|
<span class="stat-label">消息总数</span>
|
||||||
</div>
|
<span class="stat-index">03</span>
|
||||||
<div class="stat-card">
|
</article>
|
||||||
<span class="stat-icon">📈</span>
|
<article class="stat-card">
|
||||||
|
<span class="stat-icon"><UiIcon name="activity" :size="21" /></span>
|
||||||
<span class="stat-value">{{ stats.today_messages }}</span>
|
<span class="stat-value">{{ stats.today_messages }}</span>
|
||||||
<span class="stat-label">今日消息</span>
|
<span class="stat-label">今日消息</span>
|
||||||
</div>
|
<span class="stat-index">04</span>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -33,6 +37,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import UiIcon from '@/components/UiIcon.vue'
|
||||||
|
|
||||||
const stats = ref({ users: 0, conversations: 0, messages: 0, today_messages: 0 })
|
const stats = ref({ users: 0, conversations: 0, messages: 0, today_messages: 0 })
|
||||||
|
|
||||||
@@ -45,34 +50,75 @@ onMounted(async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg-secondary);
|
display: grid;
|
||||||
border: 1px solid var(--border);
|
min-height: 178px;
|
||||||
border-radius: 12px;
|
grid-template-columns: 1fr auto;
|
||||||
padding: 24px;
|
grid-template-rows: auto 1fr auto;
|
||||||
text-align: center;
|
padding: 19px;
|
||||||
|
transition: transform 220ms var(--ease-spring), border-color 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
border-color: rgba(183, 243, 107, 0.22);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 22px 52px rgba(0, 0, 0, 0.34);
|
||||||
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
font-size: 28px;
|
z-index: 1;
|
||||||
display: block;
|
display: grid;
|
||||||
margin-bottom: 8px;
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(183, 243, 107, 0.24);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 0 22px rgba(183, 243, 107, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
display: block;
|
z-index: 1;
|
||||||
font-size: 36px;
|
align-self: end;
|
||||||
font-weight: 700;
|
color: var(--text-primary);
|
||||||
color: var(--accent);
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: clamp(34px, 4vw, 48px);
|
||||||
|
font-weight: 760;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.065em;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 14px;
|
z-index: 1;
|
||||||
|
align-self: end;
|
||||||
|
margin-top: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-top: 4px;
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-index {
|
||||||
|
z-index: 1;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.stats-grid { grid-template-columns: 1fr; }
|
||||||
|
.stat-card { min-height: 150px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -184,6 +184,6 @@ async function removeDept(dept) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.danger {
|
.danger {
|
||||||
color: #ef4444;
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<div class="login-card">
|
<ThemeToggle class="login-theme-toggle" />
|
||||||
<div class="login-header">
|
<div class="login-shell">
|
||||||
<div class="logo">⚙️</div>
|
<div class="login-aside">
|
||||||
<h1>AI Chat 管理后台</h1>
|
<span class="login-mark"><UiIcon name="bolt" :size="22" /></span>
|
||||||
<p>请使用管理员账户登录</p>
|
<span class="login-eyebrow">AI CHAT / ADMIN</span>
|
||||||
|
<h1>让系统配置<br />保持清晰可控</h1>
|
||||||
|
<p>统一管理用户、模型、权限与会话数据。</p>
|
||||||
|
<span class="login-version">CONTROL SURFACE · V2</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="handleLogin">
|
<div class="login-card">
|
||||||
<div class="form-group">
|
<div class="login-header">
|
||||||
<label>账号</label>
|
<span class="status-dot" aria-hidden="true" />
|
||||||
<input v-model="account" class="form-input" placeholder="管理员用户名或邮箱" required />
|
<span>安全入口</span>
|
||||||
|
<h2>登录管理后台</h2>
|
||||||
|
<p>请使用管理员账户继续</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>密码</label>
|
<form @submit.prevent="handleLogin">
|
||||||
<input v-model="password" type="password" class="form-input" placeholder="请输入密码" required />
|
<div class="form-group">
|
||||||
</div>
|
<label>账号</label>
|
||||||
<p v-if="error" class="form-error">{{ error }}</p>
|
<input v-model="account" class="form-input" placeholder="管理员用户名或邮箱" required />
|
||||||
<button type="submit" class="btn btn-primary login-btn" :disabled="loading">
|
</div>
|
||||||
{{ loading ? '登录中...' : '登录' }}
|
<div class="form-group">
|
||||||
</button>
|
<label>密码</label>
|
||||||
</form>
|
<input v-model="password" type="password" class="form-input" placeholder="请输入密码" required />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn btn-primary login-btn" :disabled="loading">
|
||||||
|
{{ loading ? '登录中...' : '进入控制台' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,6 +41,8 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import UiIcon from '@/components/UiIcon.vue'
|
||||||
|
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -55,46 +69,159 @@ async function handleLogin() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100%;
|
position: relative;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 70% 20%, rgba(183, 243, 107, 0.08), transparent 24%),
|
||||||
|
var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: min(880px, 100%);
|
||||||
|
grid-template-columns: 1.08fr 0.92fr;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 18%;
|
||||||
|
width: 36%;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||||
|
box-shadow: 0 0 17px rgba(183, 243, 107, 0.44);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-aside {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
min-height: 530px;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
padding: 52px;
|
||||||
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
|
overflow: hidden;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(183, 243, 107, 0.07), transparent 45%),
|
||||||
|
repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.018) 0 1px, transparent 1px 14px),
|
||||||
|
#0c0f13;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
place-items: center;
|
||||||
|
margin-bottom: 44px;
|
||||||
|
border: 1px solid rgba(183, 243, 107, 0.48);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #11150e;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 5px 0 #55782f, 0 14px 28px rgba(110, 166, 54, 0.17);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-eyebrow,
|
||||||
|
.login-version {
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-aside h1 {
|
||||||
|
margin: 15px 0 18px;
|
||||||
|
font-size: clamp(34px, 4.6vw, 52px);
|
||||||
|
font-weight: 760;
|
||||||
|
letter-spacing: -0.055em;
|
||||||
|
line-height: 1.04;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-aside p {
|
||||||
|
max-width: 32ch;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-version {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 28px;
|
||||||
|
left: 52px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: 100%;
|
display: flex;
|
||||||
max-width: 400px;
|
flex-direction: column;
|
||||||
padding: 40px 32px;
|
justify-content: center;
|
||||||
background: var(--bg-secondary);
|
padding: 48px 42px;
|
||||||
border: 1px solid var(--border);
|
background:
|
||||||
border-radius: 16px;
|
radial-gradient(circle at 100% 0%, rgba(183, 243, 107, 0.055), transparent 26%),
|
||||||
|
var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header {
|
.login-header {
|
||||||
text-align: center;
|
margin-bottom: 30px;
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.login-header > span:not(.status-dot) {
|
||||||
font-size: 48px;
|
color: var(--text-muted);
|
||||||
margin-bottom: 12px;
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header h1 {
|
.status-dot {
|
||||||
font-size: 22px;
|
display: inline-block;
|
||||||
margin-bottom: 8px;
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 12px rgba(183, 243, 107, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h2 {
|
||||||
|
margin: 12px 0 7px;
|
||||||
|
font-size: 26px;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header p {
|
.login-header p {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
min-height: 46px;
|
||||||
margin-top: 8px;
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.login-page { padding: 14px; }
|
||||||
|
.login-shell { grid-template-columns: 1fr; }
|
||||||
|
.login-aside { display: none; }
|
||||||
|
.login-card { min-height: 520px; padding: 38px 25px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -242,10 +242,20 @@ async function removeLevel(level) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.membership-card {
|
.membership-card {
|
||||||
background: var(--bg-secondary);
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(circle at 100% 0%, rgba(183, 243, 107, 0.05), transparent 30%), var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035), var(--shadow-soft);
|
||||||
|
transition: transform 200ms var(--ease-spring), border-color 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-card:hover {
|
||||||
|
border-color: rgba(183, 243, 107, 0.22);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 22px 52px rgba(0, 0, 0, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -290,7 +300,8 @@ async function removeLevel(level) {
|
|||||||
.perm-tag {
|
.perm-tag {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: rgba(99, 102, 241, 0.15);
|
border: 1px solid rgba(183, 243, 107, 0.22);
|
||||||
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -320,6 +331,6 @@ async function removeLevel(level) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.danger {
|
.danger {
|
||||||
color: #ef4444;
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -32,10 +32,9 @@
|
|||||||
<span class="type-badge" :class="row.type">{{ typeLabel(row.type) }}</span>
|
<span class="type-badge" :class="row.type">{{ typeLabel(row.type) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td><code>{{ row.code }}</code></td>
|
<td><code>{{ row.code }}</code></td>
|
||||||
<td>
|
<td class="path-cell">
|
||||||
<span v-if="row.path">{{ row.path }}</span>
|
<UiIcon :name="row.path || row.type" :size="16" />
|
||||||
<span v-if="row.icon"> {{ row.icon }}</span>
|
<span>{{ row.path || row.icon || '-' }}</span>
|
||||||
<span v-if="!row.path && !row.icon">-</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.is_system ? '是' : '否' }}</td>
|
<td>{{ row.is_system ? '是' : '否' }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -93,7 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="form.type === 'menu'" class="form-group">
|
<div v-if="form.type === 'menu'" class="form-group">
|
||||||
<label>图标</label>
|
<label>图标</label>
|
||||||
<input v-model="form.icon" class="form-input" placeholder="可选 emoji" />
|
<input v-model="form.icon" class="form-input" placeholder="可选图标标识" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>排序</label>
|
<label>排序</label>
|
||||||
@@ -114,6 +113,7 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import UiIcon from '@/components/UiIcon.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const tree = ref([])
|
const tree = ref([])
|
||||||
@@ -273,9 +273,22 @@ async function removeRow(row) {
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.type-badge.dir { background: rgba(14, 165, 233, 0.15); color: #0ea5e9; }
|
.type-badge.dir { background: rgba(255, 255, 255, 0.045); color: var(--text-secondary); }
|
||||||
.type-badge.menu { background: rgba(99, 102, 241, 0.15); color: var(--accent); }
|
.type-badge.menu { background: var(--accent-soft); color: var(--accent); }
|
||||||
.type-badge.btn { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
|
.type-badge.btn { background: rgba(143, 224, 106, 0.08); color: var(--success); }
|
||||||
|
|
||||||
|
.path-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-cell :deep(svg) {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.field-hint {
|
.field-hint {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@@ -283,5 +296,5 @@ async function removeRow(row) {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger { color: #ef4444; }
|
.danger { color: var(--danger); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ async function removeRole(role) {
|
|||||||
.perm-tag {
|
.perm-tag {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: rgba(99, 102, 241, 0.15);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -446,21 +446,21 @@ async function removeRole(role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.type-badge.dir {
|
.type-badge.dir {
|
||||||
background: rgba(14, 165, 233, 0.15);
|
background: rgba(183, 243, 107, 0.07);
|
||||||
color: #0ea5e9;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-badge.menu {
|
.type-badge.menu {
|
||||||
background: rgba(99, 102, 241, 0.15);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-badge.btn {
|
.type-badge.btn {
|
||||||
background: rgba(34, 197, 94, 0.15);
|
background: rgba(143, 224, 106, 0.08);
|
||||||
color: #22c55e;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger {
|
.danger {
|
||||||
color: #ef4444;
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>系统设置</h2>
|
<h2>系统设置</h2>
|
||||||
<p>控制前端功能开关与站点配置</p>
|
<p>配置站点功能、AI 客服人物与真人感音色</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -13,40 +13,202 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="check-item">
|
<label class="check-item">
|
||||||
<input type="checkbox" v-model="allowRegister" />
|
<input v-model="allowRegister" type="checkbox" />
|
||||||
允许用户注册
|
允许用户注册
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel" style="margin-top: 16px">
|
<div class="panel voice-persona-panel">
|
||||||
|
<div class="persona-heading">
|
||||||
|
<div class="persona-preview">
|
||||||
|
<span class="persona-avatar">{{ personaInitial }}</span>
|
||||||
|
<div>
|
||||||
|
<span class="persona-kicker">AI CUSTOMER PERSONA</span>
|
||||||
|
<h3>{{ voicePersona.name || '未命名客服' }}</h3>
|
||||||
|
<p>{{ voicePersona.greeting || '设置一句自然的客服开场语' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="enable-switch">
|
||||||
|
<input v-model="voicePersona.enabled" type="checkbox" />
|
||||||
|
<span>{{ voicePersona.enabled ? 'CosyVoice 已启用' : '使用备用音色' }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preset-row">
|
||||||
|
<span>人物模板</span>
|
||||||
|
<button
|
||||||
|
v-for="preset in personaPresets"
|
||||||
|
:key="preset.id"
|
||||||
|
type="button"
|
||||||
|
class="preset-btn"
|
||||||
|
@click="applyPersonaPreset(preset)"
|
||||||
|
>{{ preset.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>客服人物名称</label>
|
||||||
|
<input v-model.trim="voicePersona.name" class="form-input" maxlength="40" placeholder="例如:小暖" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>内置说话人</label>
|
||||||
|
<input v-model.trim="voicePersona.speaker" class="form-input" maxlength="80" placeholder="中文女 / 中文男" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>前台开场语</label>
|
||||||
|
<input
|
||||||
|
v-model.trim="voicePersona.greeting"
|
||||||
|
class="form-input"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="您好,我是 AI 客服小暖,请问有什么可以帮您?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>人物性格与客服规则</label>
|
||||||
|
<textarea
|
||||||
|
v-model.trim="voicePersona.role_prompt"
|
||||||
|
class="form-input persona-textarea"
|
||||||
|
rows="3"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder="描述人物性格、服务方式、用词习惯和需要避免的表达"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CosyVoice 服务地址</label>
|
||||||
|
<input v-model.trim="voicePersona.base_url" class="form-input" placeholder="http://127.0.0.1:50000" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>合成模式</label>
|
||||||
|
<select v-model="voicePersona.mode" class="form-select">
|
||||||
|
<option value="sft">SFT 内置音色</option>
|
||||||
|
<option value="instruct">Instruct 情绪控制</option>
|
||||||
|
<option value="zero_shot">Zero-shot 克隆音色</option>
|
||||||
|
<option value="cross_lingual">跨语种克隆</option>
|
||||||
|
<option value="instruct2">Instruct2 克隆 + 情绪</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>输出采样率</label>
|
||||||
|
<select v-model.number="voicePersona.sample_rate" class="form-select">
|
||||||
|
<option :value="22050">22050 Hz · CosyVoice 1</option>
|
||||||
|
<option :value="24000">24000 Hz · CosyVoice 2/3</option>
|
||||||
|
<option :value="16000">16000 Hz</option>
|
||||||
|
<option :value="44100">44100 Hz</option>
|
||||||
|
<option :value="48000">48000 Hz</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>备用神经音色</label>
|
||||||
|
<select v-model="voicePersona.fallback_voice" class="form-select">
|
||||||
|
<option value="marin">Marin · 自然女声</option>
|
||||||
|
<option value="cedar">Cedar · 自然男声</option>
|
||||||
|
<option value="coral">Coral · 亲切明亮</option>
|
||||||
|
<option value="nova">Nova · 清晰柔和</option>
|
||||||
|
<option value="onyx">Onyx · 沉稳男声</option>
|
||||||
|
<option value="alloy">Alloy · 中性</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>单句超时</label>
|
||||||
|
<div class="input-suffix">
|
||||||
|
<input v-model.number="voicePersona.timeout_seconds" class="form-input" type="number" min="2" max="60" />
|
||||||
|
<span>秒</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>音色表达指令</label>
|
||||||
|
<textarea
|
||||||
|
v-model.trim="voicePersona.instruct_text"
|
||||||
|
class="form-input persona-textarea"
|
||||||
|
rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
placeholder="例如:温暖自然、语速适中、停顿真实,避免播音腔和夸张情绪"
|
||||||
|
/>
|
||||||
|
<p class="field-hint">Instruct / Instruct2 模式会直接使用;其他模式也会保留,切换后无需重填。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="referenceRequired" class="reference-box">
|
||||||
|
<div>
|
||||||
|
<strong>真人音色参考</strong>
|
||||||
|
<p>上传已取得说话人授权、无背景音乐的 WAV,并填写完全一致的逐字稿。</p>
|
||||||
|
</div>
|
||||||
|
<input ref="referenceInput" hidden type="file" accept=".wav,audio/wav" @change="uploadVoiceReference" />
|
||||||
|
<button type="button" class="btn btn-ghost reference-btn" :disabled="uploadingVoice" @click="referenceInput?.click()">
|
||||||
|
{{ uploadingVoice ? '上传中...' : voicePersona.prompt_wav_name || '上传 WAV 音色样本' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="voicePersona.mode === 'zero_shot'" class="form-group">
|
||||||
|
<label>参考音频逐字稿</label>
|
||||||
|
<textarea
|
||||||
|
v-model.trim="voicePersona.prompt_text"
|
||||||
|
class="form-input persona-textarea"
|
||||||
|
rows="3"
|
||||||
|
maxlength="1500"
|
||||||
|
placeholder="CosyVoice 3 示例:You are a helpful assistant.<|endofprompt|>这里填写参考音频逐字稿。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="persona-actions">
|
||||||
|
<button
|
||||||
|
v-if="auth.hasButton('btn:settings:save')"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost preview-btn"
|
||||||
|
:disabled="saving || previewingVoice"
|
||||||
|
@click="previewVoice"
|
||||||
|
>{{ previewingVoice ? '正在生成试听...' : '保存并试听音色' }}</button>
|
||||||
|
<audio v-if="previewUrl" ref="previewAudio" class="preview-audio" :src="previewUrl" controls />
|
||||||
|
<span v-if="voiceStatus" class="voice-status" :class="{ error: voiceStatusError }">{{ voiceStatus }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel feature-panel">
|
||||||
<h3 class="section-title">功能开关(会员端)</h3>
|
<h3 class="section-title">功能开关(会员端)</h3>
|
||||||
<p class="section-desc">关闭后,会员端对应功能将不可用</p>
|
<p class="section-desc">关闭后,会员端对应功能将不可用</p>
|
||||||
<div class="feature-grid">
|
<div class="feature-grid">
|
||||||
<label v-for="(val, key) in features" :key="key" class="feature-item">
|
<label v-for="(val, key) in features" :key="key" class="feature-item">
|
||||||
<input type="checkbox" v-model="features[key]" />
|
<input v-model="features[key]" type="checkbox" />
|
||||||
<span>{{ featureLabels[key] || key }}</span>
|
<span>{{ featureLabels[key] || key }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="auth.hasButton('btn:settings:save')" class="btn btn-primary" @click="saveAll" :disabled="saving">
|
<button v-if="auth.hasButton('btn:settings:save')" class="btn btn-primary" :disabled="saving" @click="saveAll">
|
||||||
{{ saving ? '保存中...' : '保存全部设置' }}
|
{{ saving ? '保存中...' : '保存全部设置' }}
|
||||||
</button>
|
</button>
|
||||||
<p v-if="saved" class="success-msg">保存成功</p>
|
<p v-if="saved" class="success-msg">保存成功</p>
|
||||||
|
<p v-if="pageError" class="form-error">{{ pageError }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const siteName = ref('AI Chat')
|
const siteName = ref('AI Chat')
|
||||||
const allowRegister = ref(true)
|
const allowRegister = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
|
const pageError = ref('')
|
||||||
|
const referenceInput = ref(null)
|
||||||
|
const uploadingVoice = ref(false)
|
||||||
|
const previewingVoice = ref(false)
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const previewAudio = ref(null)
|
||||||
|
const voiceStatus = ref('')
|
||||||
|
const voiceStatusError = ref(false)
|
||||||
|
|
||||||
const features = reactive({
|
const features = reactive({
|
||||||
markdown: true,
|
markdown: true,
|
||||||
@@ -61,6 +223,55 @@ const features = reactive({
|
|||||||
paste_image: true
|
paste_image: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const voicePersona = reactive({
|
||||||
|
enabled: true,
|
||||||
|
name: '小暖',
|
||||||
|
greeting: '您好,我是 AI 客服小暖,请问有什么可以帮您?',
|
||||||
|
role_prompt: '温暖、专业、耐心,像经验丰富的真人客服一样理解用户的真实诉求。',
|
||||||
|
base_url: 'http://127.0.0.1:50000',
|
||||||
|
mode: 'sft',
|
||||||
|
speaker: '中文女',
|
||||||
|
instruct_text: '请用温暖、自然、耐心的中文客服语气表达,语速适中,停顿真实,避免播音腔和夸张情绪。',
|
||||||
|
prompt_text: '',
|
||||||
|
prompt_wav: '',
|
||||||
|
prompt_wav_name: '',
|
||||||
|
sample_rate: 22050,
|
||||||
|
fallback_voice: 'marin',
|
||||||
|
connect_timeout_ms: 800,
|
||||||
|
timeout_seconds: 8,
|
||||||
|
failure_ttl: 20
|
||||||
|
})
|
||||||
|
|
||||||
|
const personaPresets = [
|
||||||
|
{
|
||||||
|
id: 'warm',
|
||||||
|
label: '温柔耐心',
|
||||||
|
name: '小暖',
|
||||||
|
speaker: '中文女',
|
||||||
|
fallback_voice: 'marin',
|
||||||
|
role_prompt: '温暖、专业、耐心,善于安抚情绪,像经验丰富的真人客服一样理解用户的真实诉求。',
|
||||||
|
instruct_text: '请用温暖、自然、耐心的中文客服语气表达,语速适中,停顿真实,避免播音腔和夸张情绪。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'steady',
|
||||||
|
label: '沉稳专业',
|
||||||
|
name: '阿诚',
|
||||||
|
speaker: '中文男',
|
||||||
|
fallback_voice: 'cedar',
|
||||||
|
role_prompt: '沉稳、可靠、专业,回答简洁清楚,先解决问题再补充必要信息。',
|
||||||
|
instruct_text: '请用沉稳可信、自然克制的中文男声表达,语速稍慢,停顿从容,不要播音腔。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bright',
|
||||||
|
label: '亲切活力',
|
||||||
|
name: '小晴',
|
||||||
|
speaker: '中文女',
|
||||||
|
fallback_voice: 'coral',
|
||||||
|
role_prompt: '亲切、积极、有活力,表达轻松但不过度热情,快速抓住用户重点。',
|
||||||
|
instruct_text: '请用亲切明亮、自然轻松的中文语气表达,节奏轻快,保留真实呼吸与停顿。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const featureLabels = {
|
const featureLabels = {
|
||||||
markdown: 'Markdown 解析',
|
markdown: 'Markdown 解析',
|
||||||
image: '图片解析',
|
image: '图片解析',
|
||||||
@@ -74,48 +285,343 @@ const featureLabels = {
|
|||||||
paste_image: '粘贴图片'
|
paste_image: '粘贴图片'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
const personaInitial = computed(() => (voicePersona.name || 'AI').trim().slice(0, 1).toUpperCase())
|
||||||
const res = await api.get('/admin/settings')
|
const referenceRequired = computed(() => ['zero_shot', 'cross_lingual', 'instruct2'].includes(voicePersona.mode))
|
||||||
const data = res.data.data
|
|
||||||
|
|
||||||
if (data.site_name) {
|
onMounted(loadSettings)
|
||||||
siteName.value = data.site_name.value
|
onBeforeUnmount(revokePreview)
|
||||||
}
|
|
||||||
if (data.allow_register) {
|
|
||||||
allowRegister.value = data.allow_register.value === true || data.allow_register.value === 'true'
|
|
||||||
}
|
|
||||||
if (data.features?.value) {
|
|
||||||
Object.assign(features, data.features.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function saveAll() {
|
async function loadSettings() {
|
||||||
|
pageError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/settings')
|
||||||
|
const data = res.data.data || {}
|
||||||
|
if (data.site_name) siteName.value = data.site_name.value
|
||||||
|
if (data.allow_register) {
|
||||||
|
allowRegister.value = data.allow_register.value === true || data.allow_register.value === 'true'
|
||||||
|
}
|
||||||
|
if (data.features?.value) Object.assign(features, data.features.value)
|
||||||
|
if (data.voice_persona?.value) Object.assign(voicePersona, data.voice_persona.value)
|
||||||
|
} catch (error) {
|
||||||
|
pageError.value = error.message || '设置加载失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPersonaPreset(preset) {
|
||||||
|
Object.assign(voicePersona, {
|
||||||
|
name: preset.name,
|
||||||
|
speaker: preset.speaker,
|
||||||
|
fallback_voice: preset.fallback_voice,
|
||||||
|
role_prompt: preset.role_prompt,
|
||||||
|
instruct_text: preset.instruct_text
|
||||||
|
})
|
||||||
|
voiceStatus.value = `已应用“${preset.label}”模板,保存后生效`
|
||||||
|
voiceStatusError.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAll(showMessage = true) {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
saved.value = false
|
saved.value = false
|
||||||
|
pageError.value = ''
|
||||||
try {
|
try {
|
||||||
await api.put('/admin/settings', {
|
await api.put('/admin/settings', {
|
||||||
site_name: siteName.value,
|
site_name: siteName.value,
|
||||||
allow_register: allowRegister.value ? 'true' : 'false',
|
allow_register: allowRegister.value ? 'true' : 'false',
|
||||||
features: { ...features }
|
features: { ...features },
|
||||||
|
voice_persona: { ...voicePersona }
|
||||||
})
|
})
|
||||||
saved.value = true
|
if (showMessage !== false) {
|
||||||
setTimeout(() => { saved.value = false }, 3000)
|
saved.value = true
|
||||||
|
setTimeout(() => { saved.value = false }, 3000)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
pageError.value = error.message || '保存失败'
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadVoiceReference(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
uploadingVoice.value = true
|
||||||
|
voiceStatus.value = ''
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const res = await api.post('/admin/voice/reference', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
Object.assign(voicePersona, {
|
||||||
|
prompt_wav: res.data.data.path,
|
||||||
|
prompt_wav_name: res.data.data.name
|
||||||
|
})
|
||||||
|
voiceStatus.value = `音色样本“${res.data.data.name}”已上传`
|
||||||
|
voiceStatusError.value = false
|
||||||
|
} catch (error) {
|
||||||
|
voiceStatus.value = error.message || '音色样本上传失败'
|
||||||
|
voiceStatusError.value = true
|
||||||
|
} finally {
|
||||||
|
uploadingVoice.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewVoice() {
|
||||||
|
previewingVoice.value = true
|
||||||
|
voiceStatus.value = ''
|
||||||
|
voiceStatusError.value = false
|
||||||
|
revokePreview()
|
||||||
|
try {
|
||||||
|
const savedOk = await saveAll(false)
|
||||||
|
if (!savedOk) throw new Error(pageError.value || '请先修正配置')
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
const response = await fetch('/api/admin/voice/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: `${voicePersona.greeting} 我会认真听取您的问题,并尽快为您处理。`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => null)
|
||||||
|
throw new Error(error?.message || `试听生成失败(HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
const blob = await response.blob()
|
||||||
|
previewUrl.value = URL.createObjectURL(blob)
|
||||||
|
voiceStatus.value = '试听已生成,正在播放'
|
||||||
|
await nextTick()
|
||||||
|
const playRequest = previewAudio.value?.play()
|
||||||
|
await playRequest?.catch(() => {})
|
||||||
|
} catch (error) {
|
||||||
|
voiceStatus.value = error.message || '试听生成失败'
|
||||||
|
voiceStatusError.value = true
|
||||||
|
} finally {
|
||||||
|
previewingVoice.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokePreview() {
|
||||||
|
previewAudio.value?.pause()
|
||||||
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||||
|
previewUrl.value = ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc,
|
||||||
|
.field-hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-desc {
|
.section-desc {
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-persona-panel,
|
||||||
|
.feature-panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-avatar {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
flex: 0 0 54px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(183, 243, 107, 0.42);
|
||||||
|
background: linear-gradient(145deg, var(--accent-hover), var(--accent));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45), 0 5px 0 #577c2f, 0 13px 26px rgba(110, 166, 54, 0.16);
|
||||||
|
color: #11150e;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kicker {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-preview h3 {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-preview p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-switch input,
|
||||||
|
.feature-item input,
|
||||||
|
.check-item input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-row > span {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn {
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn:hover {
|
||||||
|
border-color: rgba(183, 243, 107, 0.4);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid-3 {
|
||||||
|
grid-template-columns: 1fr 1fr 0.7fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-textarea {
|
||||||
|
min-height: 86px;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-suffix {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-suffix input {
|
||||||
|
padding-right: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-suffix span {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-box {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
background: rgba(183, 243, 107, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-box strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-box p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-actions {
|
||||||
|
min-height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-audio {
|
||||||
|
width: min(320px, 100%);
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-status {
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-status.error {
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-grid {
|
.feature-grid {
|
||||||
@@ -125,28 +631,35 @@ async function saveAll() {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-item {
|
.feature-item,
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-item input {
|
|
||||||
accent-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-item {
|
.check-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-msg {
|
.success-msg {
|
||||||
|
margin-top: 12px;
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 12px;
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.persona-heading,
|
||||||
|
.reference-box {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-switch {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid,
|
||||||
|
.form-grid-3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ function formatDate(d) {
|
|||||||
.perm-tag {
|
.perm-tag {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: rgba(99, 102, 241, 0.15);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -433,6 +433,6 @@ function formatDate(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.danger {
|
.danger {
|
||||||
color: #ef4444;
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+929
-92
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,10 @@
|
|||||||
<span class="brand-badge">AI</span>
|
<span class="brand-badge">AI</span>
|
||||||
<div class="brand-copy">
|
<div class="brand-copy">
|
||||||
<strong>{{ settings.siteName || 'AI Chat' }}</strong>
|
<strong>{{ settings.siteName || 'AI Chat' }}</strong>
|
||||||
<p>统一文本与图片创作</p>
|
<p>TEXT · IMAGE · AGENT</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-close" type="button" aria-label="关闭会话列表" @click="chat.closeSidebar">
|
<button class="sidebar-close" type="button" aria-label="关闭会话列表" @click="chat.closeSidebar">
|
||||||
<IconX :size="19" :stroke-width="1.8" />
|
<IconX :size="18" :stroke-width="1.8" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="conversation-list">
|
<div class="conversation-list">
|
||||||
<div class="conversation-heading">
|
<div class="conversation-heading">
|
||||||
<span>最近对话</span>
|
<span>最近对话</span>
|
||||||
<span>{{ chat.conversations.length }}</span>
|
<span>{{ chat.conversations.length.toString().padStart(2, '0') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -32,7 +32,9 @@
|
|||||||
:aria-current="conv.id === chat.currentId ? 'page' : undefined"
|
:aria-current="conv.id === chat.currentId ? 'page' : undefined"
|
||||||
@click="selectConv(conv.id)"
|
@click="selectConv(conv.id)"
|
||||||
>
|
>
|
||||||
<IconMessageCircle class="conversation-icon" :size="17" :stroke-width="1.7" />
|
<span class="conversation-icon-wrap">
|
||||||
|
<IconMessageCircle class="conversation-icon" :size="16" :stroke-width="1.7" />
|
||||||
|
</span>
|
||||||
<span class="conv-title">{{ conv.title }}</span>
|
<span class="conv-title">{{ conv.title }}</span>
|
||||||
<button
|
<button
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
@@ -40,11 +42,11 @@
|
|||||||
:aria-label="`删除对话:${conv.title}`"
|
:aria-label="`删除对话:${conv.title}`"
|
||||||
@click.stop="handleDelete(conv.id)"
|
@click.stop="handleDelete(conv.id)"
|
||||||
>
|
>
|
||||||
<IconTrash :size="15" :stroke-width="1.8" />
|
<IconTrash :size="14" :stroke-width="1.8" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!chat.conversations.length" class="empty-tip">还没有会话,先创建一个新对话</p>
|
<p v-if="!chat.conversations.length" class="empty-tip">还没有会话<br />点击上方按钮开始创作</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
<span class="user-name">{{ auth.user?.nickname || auth.user?.username }}</span>
|
<span class="user-name">{{ auth.user?.nickname || auth.user?.username }}</span>
|
||||||
<span class="user-level">{{ auth.isGuest ? '游客模式 · 自动保存' : (auth.user?.membership_name || '普通用户') }}</span>
|
<span class="user-level">{{ auth.isGuest ? '游客模式 · 自动保存' : (auth.user?.membership_name || '普通用户') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="user-online" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -122,45 +125,62 @@ async function handleDelete(id) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
position: relative;
|
||||||
background: rgba(255, 255, 255, 0.94);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex: 0 0 var(--sidebar-width);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
overflow: hidden;
|
||||||
transition: transform 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
border-right: 1px solid var(--border);
|
||||||
backdrop-filter: blur(14px);
|
background:
|
||||||
|
radial-gradient(circle at 40% -12%, rgba(183, 243, 107, 0.075), transparent 24%),
|
||||||
|
rgba(13, 16, 21, 0.96);
|
||||||
|
box-shadow: 18px 0 50px rgba(0, 0, 0, 0.16);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
transition: transform 260ms var(--ease-spring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -1px;
|
||||||
|
width: 1px;
|
||||||
|
height: 24%;
|
||||||
|
background: linear-gradient(to bottom, var(--accent), transparent);
|
||||||
|
box-shadow: 0 0 16px rgba(183, 243, 107, 0.42);
|
||||||
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-top {
|
.sidebar-top {
|
||||||
padding: 16px 14px 12px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 17px;
|
||||||
|
padding: 18px 14px 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 42px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 11px;
|
||||||
min-height: 40px;
|
padding: 1px 2px;
|
||||||
padding: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-badge {
|
.brand-badge {
|
||||||
|
display: grid;
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(183, 243, 107, 0.5);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--accent-soft);
|
background: var(--accent);
|
||||||
border: 1px solid #d9e6fb;
|
color: #11150e;
|
||||||
color: var(--accent);
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
display: flex;
|
font-size: 12px;
|
||||||
align-items: center;
|
font-weight: 800;
|
||||||
justify-content: center;
|
letter-spacing: -0.04em;
|
||||||
font-size: 14px;
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 4px 0 #55782f, 0 10px 22px rgba(110, 166, 54, 0.16);
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy {
|
.brand-copy {
|
||||||
@@ -173,17 +193,19 @@ async function handleDelete(id) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 650;
|
font-weight: 690;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand p {
|
.sidebar-brand p {
|
||||||
margin-top: 2px;
|
margin-top: 3px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
line-height: 1.3;
|
font-size: 8px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-close {
|
.sidebar-close {
|
||||||
@@ -192,81 +214,99 @@ async function handleDelete(id) {
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-chat-btn {
|
.new-chat-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 43px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
width: 100%;
|
|
||||||
min-height: 42px;
|
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
background: var(--accent);
|
border: 1px solid rgba(183, 243, 107, 0.62);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: #fff;
|
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
|
||||||
|
color: #11150e;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 720;
|
||||||
box-shadow: 0 7px 18px rgba(45, 102, 218, 0.2);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.48), 0 5px 0 #577c2f, 0 13px 24px rgba(126, 183, 67, 0.13);
|
||||||
transition: background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
transition: transform 180ms var(--ease-spring), background 160ms ease, box-shadow 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-chat-btn:hover {
|
.new-chat-btn:hover {
|
||||||
background: var(--accent-hover);
|
background: linear-gradient(180deg, #d1ff9c, var(--accent-hover));
|
||||||
box-shadow: 0 9px 22px rgba(45, 102, 218, 0.24);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52), 0 6px 0 #5f8538, 0 15px 28px rgba(126, 183, 67, 0.18);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-chat-btn:active {
|
.new-chat-btn:active {
|
||||||
transform: scale(0.98);
|
transform: translateY(4px) scale(0.99);
|
||||||
|
box-shadow: inset 0 2px 5px rgba(68, 96, 37, 0.22), 0 1px 0 #577c2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-list {
|
.conversation-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
padding: 5px 10px 14px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 6px 9px 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-heading {
|
.conversation-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 9px 7px;
|
padding: 10px 9px 8px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item {
|
.conversation-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 43px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
min-height: 42px;
|
margin-bottom: 3px;
|
||||||
margin-bottom: 2px;
|
padding: 7px 8px;
|
||||||
padding: 8px 8px 8px 10px;
|
border: 1px solid transparent;
|
||||||
border-radius: 10px;
|
border-radius: 11px;
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
font-size: 12px;
|
||||||
|
transition: transform 170ms var(--ease-spring), border-color 160ms ease, background 160ms ease, color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item:hover {
|
.conversation-item:hover {
|
||||||
background: var(--bg-tertiary);
|
border-color: var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item.active {
|
.conversation-item.active {
|
||||||
background: var(--accent-soft);
|
border-color: rgba(183, 243, 107, 0.25);
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
background: linear-gradient(90deg, rgba(183, 243, 107, 0.13), rgba(183, 243, 107, 0.04));
|
||||||
color: var(--text-primary);
|
color: var(--accent);
|
||||||
font-weight: 500;
|
}
|
||||||
|
|
||||||
|
.conversation-icon-wrap {
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-icon {
|
.conversation-icon {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item.active .conversation-icon {
|
.conversation-item.active .conversation-icon {
|
||||||
@@ -281,16 +321,15 @@ async function handleDelete(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
|
display: grid;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
display: flex;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
place-items: center;
|
||||||
justify-content: center;
|
border-radius: 8px;
|
||||||
border-radius: 50%;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
flex-shrink: 0;
|
transition: opacity 150ms ease, background 150ms ease, color 150ms ease, transform 150ms ease;
|
||||||
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item:hover .delete-btn,
|
.conversation-item:hover .delete-btn,
|
||||||
@@ -299,63 +338,78 @@ async function handleDelete(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover {
|
.delete-btn:hover {
|
||||||
background: #fff0f0;
|
background: var(--danger-soft);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
transform: rotate(3deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-tip {
|
.empty-tip {
|
||||||
padding: 26px 18px;
|
margin: 18px 8px;
|
||||||
|
padding: 25px 16px;
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
border-radius: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 10px 12px 12px;
|
padding: 11px 13px 13px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 6px;
|
padding: 6px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
|
display: grid;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border-radius: 11px;
|
flex: 0 0 auto;
|
||||||
background: #eef1f6;
|
place-items: center;
|
||||||
color: var(--text-primary);
|
border: 1px solid rgba(183, 243, 107, 0.28);
|
||||||
display: flex;
|
border-radius: 10px;
|
||||||
align-items: center;
|
background: var(--accent-soft);
|
||||||
justify-content: center;
|
color: var(--accent);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 750;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-meta {
|
.user-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 550;
|
font-weight: 650;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-level {
|
.user-level {
|
||||||
margin-top: 2px;
|
margin-top: 3px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-online {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 11px rgba(183, 243, 107, 0.58);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -363,30 +417,11 @@ async function handleDelete(id) {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
width: min(84vw, 304px);
|
width: min(86vw, 310px);
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
box-shadow: 20px 0 50px rgba(21, 32, 51, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.open {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-close {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn {
|
|
||||||
opacity: 0.68;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.sidebar,
|
|
||||||
.new-chat-btn,
|
|
||||||
.conversation-item,
|
|
||||||
.delete-btn {
|
|
||||||
transition: none;
|
|
||||||
}
|
}
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
.sidebar-close { display: flex; }
|
||||||
|
.delete-btn { opacity: 0.66; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1082,4 +1082,36 @@ function zoomByWheel(event) { zoom.value = Math.min(3, Math.max(0.25, zoom.value
|
|||||||
@media(max-width:1100px){.ratio-options button{padding:0 7px}.ratio-options button>i{display:none}.workflow-panel{width:calc(100% - 24px)}.primary-workflow-button{min-width:96px;padding:0 12px}}
|
@media(max-width:1100px){.ratio-options button{padding:0 7px}.ratio-options button>i{display:none}.workflow-panel{width:calc(100% - 24px)}.primary-workflow-button{min-width:96px;padding:0 12px}}
|
||||||
@media(max-width:800px){.workflow-panel{position:absolute;right:8px;bottom:calc(46dvh + 18px);left:8px;z-index:3;width:auto;margin:0}.outpaint-panel{overflow-x:auto;justify-content:flex-start}.outpaint-frame{width:min(94vw,calc(38dvh * var(--outpaint-ratio)));height:min(38dvh,520px);max-width:94vw}.restore-overlay{padding-bottom:16px}.restore-progress{min-height:46px;padding:0 12px;font-size:12px}}
|
@media(max-width:800px){.workflow-panel{position:absolute;right:8px;bottom:calc(46dvh + 18px);left:8px;z-index:3;width:auto;margin:0}.outpaint-panel{overflow-x:auto;justify-content:flex-start}.outpaint-frame{width:min(94vw,calc(38dvh * var(--outpaint-ratio)));height:min(38dvh,520px);max-width:94vw}.restore-overlay{padding-bottom:16px}.restore-progress{min-height:46px;padding:0 12px;font-size:12px}}
|
||||||
@media(prefers-reduced-motion:reduce){.workflow-panel button:active,.tool-bar button:active{transform:none}}
|
@media(prefers-reduced-motion:reduce){.workflow-panel button:active,.tool-bar button:active{transform:none}}
|
||||||
|
|
||||||
|
/* Uiverse-inspired image workbench */
|
||||||
|
.workbench{background:#090b0f;color:var(--text-primary)}
|
||||||
|
.workbench-sidebar,.tool-bar,.canvas-footer{border-color:var(--border);background:#101319;color:var(--text-primary)}
|
||||||
|
.sidebar-header,.ops-panel,.command-log{border-color:var(--border)}
|
||||||
|
.sidebar-header span,.composer-footer span,.ops-title,.tool-note p,.outpaint-side-note p,.command-log strong{color:var(--text-muted)}
|
||||||
|
.icon-button,.tool-bar>button:not(.icon-button),.canvas-footer button,.selection-tools>button,.ratio-options button{color:var(--text-secondary)}
|
||||||
|
.icon-button:hover,.tool-bar>button:hover,.tool-bar>button.active,.canvas-footer button:hover,.selection-tools>button:hover,.selection-tools>button.active,.ratio-options button:hover,.ratio-options button.active{background:var(--accent-soft);color:var(--accent)}
|
||||||
|
.result-strip button img{border-color:var(--border);background:var(--bg-tertiary);box-shadow:0 10px 26px rgba(0,0,0,.28)}
|
||||||
|
.result-strip button.active img{border-color:var(--accent);box-shadow:0 0 0 3px rgba(183,243,107,.08),0 12px 28px rgba(0,0,0,.34)}
|
||||||
|
.tool-chip{border:1px solid rgba(183,243,107,.2);background:var(--accent-soft);color:var(--accent)}
|
||||||
|
.clear-mask,.command-tool,.status,.panel-status{color:var(--accent)}
|
||||||
|
.outpaint-side-note,.command-log li{border-color:var(--border);background:var(--surface-inset)}
|
||||||
|
.outpaint-side-note span{color:var(--accent)}
|
||||||
|
.command-text{color:var(--text-secondary)}
|
||||||
|
.suggestions button{border-color:var(--border);background:rgba(255,255,255,.02);color:var(--text-secondary)}
|
||||||
|
.suggestions button:hover{border-color:var(--accent-line);background:var(--accent-soft);color:var(--accent)}
|
||||||
|
.composer,.workflow-panel{border-color:var(--border-strong);background:var(--bg-secondary);box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 16px 40px rgba(0,0,0,.34)}
|
||||||
|
.composer textarea,.workflow-command input{color:var(--text-primary)}
|
||||||
|
.composer textarea::placeholder,.workflow-command input::placeholder{color:var(--text-muted)}
|
||||||
|
.composer-footer button,.workflow-command>button,.primary-workflow-button{border:1px solid rgba(183,243,107,.54);background:linear-gradient(180deg,var(--accent-hover),var(--accent));color:#11150e;box-shadow:inset 0 1px 0 rgba(255,255,255,.45),0 4px 0 #577c2f}
|
||||||
|
.composer-footer button:active,.workflow-command>button:active,.primary-workflow-button:active{transform:translateY(3px);box-shadow:0 1px 0 #577c2f}
|
||||||
|
.workflow-command>svg{color:var(--accent)}
|
||||||
|
.selection-tools,.panel-divider,.selection-tools>i{border-color:var(--border);background-color:var(--border)}
|
||||||
|
.background-toggle{color:var(--text-secondary)}
|
||||||
|
.canvas-shell{background-color:#0b0e12;background-image:radial-gradient(rgba(255,255,255,.05) .8px,transparent .8px);background-size:18px 18px}
|
||||||
|
.canvas-stage{background-color:#11151a;background-image:linear-gradient(45deg,#191e25 25%,transparent 25%),linear-gradient(-45deg,#191e25 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#191e25 75%),linear-gradient(-45deg,transparent 75%,#191e25 75%)}
|
||||||
|
.canvas-stage img{box-shadow:0 24px 64px rgba(0,0,0,.5)}
|
||||||
|
.outpaint-frame{border-color:var(--border-strong);background-color:#0e1217;background-image:radial-gradient(circle,#2b332d 1px,transparent 1.1px);box-shadow:inset 0 0 0 1px rgba(255,255,255,.025)}
|
||||||
|
.restore-progress{border-color:var(--border-strong);background:rgba(16,19,25,.96);color:var(--text-primary);box-shadow:0 18px 48px rgba(0,0,0,.48)}
|
||||||
|
.restore-progress>svg,.restore-progress button{color:var(--accent)}
|
||||||
|
@media(max-width:800px){.workbench-sidebar{border-color:var(--border-strong);background:var(--bg-secondary);box-shadow:0 22px 60px rgba(0,0,0,.5)}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -795,4 +795,94 @@ async function copyContent() {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Uiverse-inspired message surfaces */
|
||||||
|
.message {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 7px 18px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-avatar {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .message-avatar {
|
||||||
|
border-color: rgba(183, 243, 107, 0.28);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 0 20px rgba(183, 243, 107, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-content {
|
||||||
|
border-color: rgba(183, 243, 107, 0.23);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(183, 243, 107, 0.12), rgba(183, 243, 107, 0.055)),
|
||||||
|
var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 10px 28px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .message-content {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn:hover,
|
||||||
|
.message-action-btn:focus-visible {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(pre) {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: #080a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-content :deep(pre),
|
||||||
|
.message.user .message-content :deep(:not(pre) > code) {
|
||||||
|
background: rgba(4, 7, 5, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-loading-grid i {
|
||||||
|
background: linear-gradient(112deg, #11151a 8%, #20262c 38%, #151b1c 64%, #11151a 92%);
|
||||||
|
background-size: 240% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-progress {
|
||||||
|
border-color: rgba(183, 243, 107, 0.24);
|
||||||
|
background: rgba(13, 17, 21, 0.9);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: 0 7px 20px rgba(0, 0, 0, 0.26);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-image,
|
||||||
|
.att-video {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-document {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 8px 22px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-document:hover {
|
||||||
|
border-color: var(--accent-line);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -547,4 +547,101 @@ watch(
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Uiverse-inspired dark component treatment */
|
||||||
|
.message-list {
|
||||||
|
padding-top: 32px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-bottom-btn {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: rgba(18, 22, 28, 0.92);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 12px 34px rgba(0, 0, 0, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-bottom-btn:hover {
|
||||||
|
border-color: var(--accent-line);
|
||||||
|
background: #171c22;
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 14px 38px rgba(0, 0, 0, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-panel {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 680px);
|
||||||
|
padding: 34px 26px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-panel::before {
|
||||||
|
display: block;
|
||||||
|
width: 46px;
|
||||||
|
height: 1px;
|
||||||
|
margin: 0 auto 22px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 14px rgba(183, 243, 107, 0.48);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin-bottom: 23px;
|
||||||
|
border-color: rgba(183, 243, 107, 0.5);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #11150e;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 5px 0 #577c2f, 0 15px 30px rgba(110, 166, 54, 0.17);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome h2 {
|
||||||
|
max-width: 590px;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-bottom: 13px;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: clamp(27px, 3vw, 38px);
|
||||||
|
font-weight: 760;
|
||||||
|
letter-spacing: -0.055em;
|
||||||
|
line-height: 1.16;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome p {
|
||||||
|
max-width: 500px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state span {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: linear-gradient(100deg, #15191f 15%, #20262e 38%, #15191f 62%);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
width: min(100%, var(--input-max-width));
|
||||||
|
padding-left: 51px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 9px rgba(183, 243, 107, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-overlay {
|
||||||
|
background: rgba(4, 5, 7, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-close {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: rgba(18, 22, 28, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.message-list { padding-top: 20px; }
|
||||||
|
.welcome-panel { padding: 22px 8px 30px; }
|
||||||
|
.welcome h2 { font-size: 25px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -289,4 +289,47 @@ async function copyDetail(item) {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Uiverse-inspired notification surfaces */
|
||||||
|
.notification-card {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(183, 243, 107, 0.05), transparent 32%),
|
||||||
|
rgba(16, 19, 25, 0.98);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 20px 56px rgba(0, 0, 0, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-card::before {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 12px rgba(183, 243, 107, 0.44);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
border: 1px solid rgba(255, 126, 121, 0.22);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close,
|
||||||
|
.copy-detail {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover,
|
||||||
|
.copy-detail:hover {
|
||||||
|
border-color: var(--accent-line);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-detail,
|
||||||
|
.detail-body,
|
||||||
|
.detail-body pre {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="theme-toggle" role="group" aria-label="界面主题">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ active: theme === 'light' }"
|
||||||
|
:aria-pressed="theme === 'light'"
|
||||||
|
aria-label="使用浅色主题"
|
||||||
|
title="浅色主题"
|
||||||
|
@click="setTheme('light')"
|
||||||
|
>
|
||||||
|
<span>浅色</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ active: theme === 'dark' }"
|
||||||
|
:aria-pressed="theme === 'dark'"
|
||||||
|
aria-label="使用深色主题"
|
||||||
|
title="深色主题"
|
||||||
|
@click="setTheme('dark')"
|
||||||
|
>
|
||||||
|
<span>深色</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: repeat(2, 42px);
|
||||||
|
gap: 0;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 30px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--button-primary-bg);
|
||||||
|
color: var(--button-primary-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user