增加ai配置和ai广场功能

This commit is contained in:
hero920103 committed 2026-11-09 16:44:11 +08:00
1 parent 244b4bd9fd
commit d9a50e23dd
27 files changed
+3808 -57

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
import request from "@/utils/request";
/** AI 服务商列表(可按 provider_type 过滤) */
export function getAiProviderList(params) {
return request({ url: "/platform/ai/provider/list", method: "get", params });
}
export function createAiProvider(data) {
return request({ url: "/platform/ai/provider", method: "post", data });
}
export function updateAiProvider(id, data) {
return request({ url: `/platform/ai/provider/${id}`, method: "put", data });
}
export function deleteAiProvider(id) {
return request({ url: `/platform/ai/provider/${id}`, method: "delete" });
}
/** 连通性测试 { id, model? } */
export function testAiProvider(data) {
return request({ url: "/platform/ai/provider/test", method: "post", data });
}
/** Playground 对话 { provider_id, model, messages } */
export function aiChat(data) {
return request({ url: "/platform/ai/chat", method: "post", data });
}
export function getOcrConfig() {
return request({ url: "/platform/ai/ocrConfig", method: "get" });
}
export function saveOcrConfig(data) {
return request({ url: "/platform/ai/ocrConfig", method: "post", data });
}
/** OCR 识别占位接口 */
export function ocrRecognize(data) {
return request({ url: "/platform/ai/ocr/recognize", method: "post", data });
}
/** AI 会话列表 */
export function getAiChatSessions() {
return request({ url: "/platform/ai/chat/sessions", method: "get" });
}
/** 新建 AI 会话 { provider_id, model } */
export function createAiChatSession(data) {
return request({ url: "/platform/ai/chat/session", method: "post", data });
}
/** 会话消息列表 */
export function getAiChatSessionMessages(id) {
return request({ url: `/platform/ai/chat/session/${id}/messages`, method: "get" });
}
/** 重命名 / 置顶会话 { title?, is_top? } */
export function updateAiChatSession(id, data) {
return request({ url: `/platform/ai/chat/session/${id}`, method: "put", data });
}
/** 删除会话 */
export function deleteAiChatSession(id) {
return request({ url: `/platform/ai/chat/session/${id}`, method: "delete" });
}
@@ -0,0 +1,178 @@
<template>
<div class="ocr-settings" v-loading="loading">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>OCR 识别配置</span>
<!-- <span class="card-tip">租户端「OA - OCR 识别」读取此配置;识别实现后续接入</span> -->
</div>
</template>
<el-form :model="form" label-width="130px" style="max-width: 720px">
<el-form-item label="启用 OCR">
<el-switch v-model="form.ocr_enabled" active-value="1" inactive-value="0" />
<span class="form-tip" style="margin-left: 10px">关闭后租户端 OCR 功能不可用</span>
</el-form-item>
<el-form-item label="识别服务商">
<el-select v-model="form.ocr_provider_id" placeholder="选择已启用的服务商配置" style="width: 320px" clearable @change="onProviderChange">
<el-option
v-for="p in providers"
:key="p.id"
:label="`${p.name}(${apiTypeLabel(p.api_type)})`"
:value="String(p.id)"
/>
</el-select>
<div class="form-tip">从上方已保存的模型配置中选择</div>
</el-form-item>
<el-form-item label="识别模型">
<el-select
v-model="form.ocr_model"
placeholder="先选择服务商,再选择模型"
style="width: 320px"
clearable
:disabled="!modelOptions.length"
>
<el-option
v-for="m in modelOptions"
:key="m.id"
:label="m.name ? `${m.name}(${m.id})` : m.id"
:value="m.id"
/>
</el-select>
<div v-if="!modelOptions.length" class="form-tip">该服务商暂无可用模型</div>
</el-form-item>
<el-form-item label="识别提示词">
<el-input
v-model="form.ocr_prompt"
type="textarea"
:rows="3"
maxlength="500"
placeholder="留空使用默认:请把图片中的文字完整识别出来,保持原有排版"
/>
</el-form-item>
</el-form>
<div class="footer-actions">
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存配置</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, computed } from "vue";
import { ElMessage } from "element-plus";
import { getOcrConfig, saveOcrConfig, getAiProviderList } from "@/api/aisettings";
const loading = ref(false);
const submitting = ref(false);
const providers = ref([]);
const form = reactive({
ocr_enabled: "0",
ocr_provider_id: "",
ocr_model: "",
ocr_prompt: "",
});
let snapshot = { ...form };
const API_TYPE_LABELS = {
chat_completions: "Chat Completions",
responses: "Responses",
anthropic: "Anthropic Messages",
};
const apiTypeLabel = (t) => API_TYPE_LABELS[t] || t || "Chat Completions";
const currentProvider = computed(() =>
providers.value.find((p) => String(p.id) === String(form.ocr_provider_id))
);
const modelOptions = computed(() => {
const p = currentProvider.value;
if (!p) return [];
return (p.models || []).map((m) => ({
id: m.id || m,
name: m.name || "",
}));
});
const onProviderChange = () => {
// 切换服务商后,若当前模型不在新列表中则清空
const ids = modelOptions.value.map((m) => m.id);
if (form.ocr_model && !ids.includes(form.ocr_model)) {
form.ocr_model = "";
}
};
const loadConfig = async () => {
loading.value = true;
try {
const [cfgRes, listRes] = await Promise.all([getOcrConfig(), getAiProviderList()]);
if (cfgRes?.code === 200 && cfgRes.data) {
Object.assign(form, {
ocr_enabled: cfgRes.data.ocr_enabled || "0",
ocr_provider_id: cfgRes.data.ocr_provider_id || "",
ocr_model: cfgRes.data.ocr_model || "",
ocr_prompt: cfgRes.data.ocr_prompt || "",
});
snapshot = { ...form };
}
if (listRes?.code === 200) {
providers.value = (listRes.data || []).filter((p) => p.status === 1);
}
} finally {
loading.value = false;
}
};
const handleSubmit = async () => {
submitting.value = true;
try {
const res = await saveOcrConfig({ ...form });
if (res?.code === 200) {
ElMessage.success("保存成功");
snapshot = { ...form };
} else {
ElMessage.error(res?.msg || "保存失败");
}
} finally {
submitting.value = false;
}
};
const handleReset = () => {
Object.assign(form, snapshot);
ElMessage.info("已恢复到上次保存的配置");
};
onMounted(loadConfig);
</script>
<style lang="less" scoped>
.ocr-settings {
.card-header {
display: flex;
align-items: baseline;
gap: 12px;
font-weight: 600;
font-size: 15px;
}
.card-tip {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.form-tip {
width: 100%;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
margin-top: 4px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
</style>
@@ -0,0 +1,207 @@
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑模型配置' : '新增模型配置'"
width="720px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@open="onOpen"
>
<el-form :model="form" label-width="110px">
<el-form-item label="配置名称" required>
<el-input v-model="form.name" placeholder="如:DeepSeek 生产环境" maxlength="100" />
</el-form-item>
<el-form-item label="API 地址" required>
<el-input v-model="form.api_base" :placeholder="apiBasePlaceholder" />
<div class="form-tip">{{ apiTypeTip }}</div>
</el-form-item>
<el-form-item label="API 密钥" :required="!isEdit">
<el-input
v-model="form.api_key"
type="password"
show-password
:placeholder="isEdit ? '留空表示不修改(当前:' + (item?.api_key_masked || '未设置') + ')' : 'sk-...'"
/>
</el-form-item>
<el-form-item label="API 协议" required>
<el-select v-model="form.api_type" style="width: 100%" :disabled="isEdit">
<el-option label="OpenAI Chat Completions" value="chat_completions" />
<el-option label="OpenAI Responses" value="responses" />
<el-option label="Anthropic Messages" value="anthropic" />
</el-select>
</el-form-item>
<el-form-item label="模型列表">
<div class="models-table">
<el-table :data="form.models" size="small" border>
<el-table-column label="模型名称" min-width="160">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="展示名称,如 GPT-4o" size="small" />
</template>
</el-table-column>
<el-table-column label="模型 ID" min-width="200">
<template #default="{ row }">
<el-input v-model="row.id" placeholder="如 gpt-4o / deepseek-chat" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="form.models.splice($index, 1)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-button class="add-model-btn" size="small" @click="addModelRow">
<el-icon><Plus /></el-icon> 添加模型
</el-button>
</div>
</el-form-item>
<el-form-item label="默认模型">
<el-select v-model="form.default_model" placeholder="选择默认模型" style="width: 100%" clearable>
<el-option
v-for="(m, i) in form.models"
:key="i"
:label="m.name ? `${m.name}(${m.id})` : m.id"
:value="m.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设为平台默认">
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
<span class="form-tip" style="margin-left: 10px">Playground / OCR 未指定时优先使用</span>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" maxlength="255" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { reactive, ref, computed } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { createAiProvider, updateAiProvider } from "@/api/aisettings";
const props = defineProps({
modelValue: { type: Boolean, default: false },
/** null/undefined = 新增;有 id = 编辑 */
item: { type: Object, default: null },
presetApiType: { type: String, default: "chat_completions" },
});
const emit = defineEmits(["update:modelValue", "saved"]);
const API_TYPE_TIPS = {
chat_completions: "OpenAI Chat Completions:POST {api}/chat/completions,Bearer 鉴权",
responses: "OpenAI Responses:POST {api}/responses,Bearer 鉴权",
anthropic: "Anthropic Messages:POST {api}/v1/messages,x-api-key + anthropic-version 鉴权",
};
const API_BASE_PLACEHOLDERS = {
chat_completions: "https://api.deepseek.com",
responses: "https://api.openai.com/v1",
anthropic: "https://api.anthropic.com",
};
const isEdit = computed(() => !!props.item?.id);
const apiTypeTip = computed(() => API_TYPE_TIPS[form.api_type] || "");
const apiBasePlaceholder = computed(() => API_BASE_PLACEHOLDERS[form.api_type] || "https://...");
const submitting = ref(false);
const form = reactive({
name: "",
api_base: "",
api_key: "",
api_type: "chat_completions",
models: [],
default_model: "",
is_default: 0,
status: 1,
remark: "",
});
const onOpen = () => {
const t = props.item?.api_type || props.presetApiType || "chat_completions";
Object.assign(form, {
name: props.item?.name || "",
api_base: props.item?.api_base || "",
api_key: "",
api_type: t,
models: (props.item?.models || []).map((m) => ({ id: m.id || "", name: m.name || "" })),
default_model: props.item?.default_model || "",
is_default: props.item?.is_default || 0,
status: props.item?.status ?? 1,
remark: props.item?.remark || "",
});
if (!form.models.length) addModelRow();
};
const addModelRow = () => {
form.models.push({ id: "", name: "" });
};
const handleSubmit = async () => {
if (!form.name.trim()) return ElMessage.warning("请填写配置名称");
if (!form.api_base.trim()) return ElMessage.warning("请填写 API 地址");
if (!isEdit.value && !form.api_key.trim()) return ElMessage.warning("请填写 API 密钥");
const models = form.models
.map((m) => ({ id: (m.id || "").trim(), name: (m.name || "").trim() }))
.filter((m) => m.id);
if (!models.length) return ElMessage.warning("请至少添加一个模型 ID");
submitting.value = true;
try {
const data = {
api_type: form.api_type,
name: form.name.trim(),
api_base: form.api_base.trim().replace(/\/+$/, ""),
api_key: form.api_key.trim(),
models,
default_model: form.default_model,
is_default: form.is_default,
status: form.status,
remark: form.remark,
};
const res = isEdit.value
? await updateAiProvider(props.item.id, data)
: await createAiProvider({ ...data, provider_type: "custom" });
if (res?.code === 200) {
ElMessage.success("保存成功");
emit("update:modelValue", false);
emit("saved", { id: res.data?.id || props.item?.id });
} else {
ElMessage.error(res?.msg || "保存失败");
}
} finally {
submitting.value = false;
}
};
</script>
<style lang="less" scoped>
.form-tip {
width: 100%;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
margin-top: 4px;
}
.models-table {
width: 100%;
.add-model-btn {
margin-top: 8px;
}
}
</style>
@@ -0,0 +1,288 @@
<template>
<div class="container-box aisettings">
<div class="header-bar">
<h2>AI 管理</h2>
<el-button type="primary" plain @click="$router.push('/system/aisettings/playground')">
打开 Playground
</el-button>
</div>
<!-- 顶部引导 -->
<div class="ai-guide">
<div class="ai-guide__text">
<h3>统一 AI 能力中台</h3>
<p>
左侧管理已接入的模型配置(OpenAI Chat Completions / OpenAI Responses / Anthropic Messages),
点击「新增」或列表项进行配置;连通性测试与 Playground 对话验证已保存的模型。
OCR 识别配置在右侧统一维护,租户端「OA - OCR 识别」将读取这里的配置。
</p>
</div>
<div class="ai-guide__steps">
<span class="step"><i>1</i>新增模型</span>
<span class="step"><i>2</i>填写 API 信息</span>
<span class="step"><i>3</i>连通性测试</span>
<span class="step"><i>4</i>Playground 验证</span>
</div>
</div>
<div class="ai-layout">
<!-- 左侧:已配置模型列表 -->
<div class="ai-list">
<div class="ai-list__header">
<span class="ai-list__title">模型配置</span>
<el-button type="primary" size="small" @click="openAdd">新增</el-button>
</div>
<div class="ai-list__body" v-loading="listLoading">
<div
v-for="item in list"
:key="item.id"
class="ai-item"
@click="openEdit(item)"
>
<div class="ai-item__main">
<div class="ai-item__name">
{{ item.name }}
<el-tag v-if="item.is_default === 1" type="success" size="small">默认</el-tag>
</div>
<div class="ai-item__meta">
{{ apiTypeLabel(item.api_type) }} · {{ firstModel(item) }}
</div>
</div>
<el-tag :type="item.status === 1 ? 'success' : 'info'" size="small" effect="plain">
{{ item.status === 1 ? '启用' : '禁用' }}
</el-tag>
</div>
<el-empty v-if="!listLoading && !list.length" description="暂无配置,点击新增创建" :image-size="60" />
</div>
</div>
<!-- 右侧:OCR 识别配置 -->
<div class="ai-detail">
<ocr-settings />
</div>
</div>
<!-- 新增 / 编辑弹窗 -->
<provider-dialog
v-model="dialogVisible"
:item="dialogItem"
@saved="onSaved"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { getAiProviderList } from "@/api/aisettings";
import providerDialog from "./components/providerDialog.vue";
import ocrSettings from "./components/ocrSettings.vue";
const listLoading = ref(false);
const list = ref([]);
const dialogVisible = ref(false);
const dialogItem = ref(null);
const API_TYPE_LABELS = {
chat_completions: "Chat Completions",
responses: "Responses",
anthropic: "Anthropic Messages",
};
const apiTypeLabel = (t) => API_TYPE_LABELS[t] || t || "Chat Completions";
const firstModel = (item) => {
const m = (item.models || [])[0];
if (!m) return item.default_model || "未设置模型";
return m.name ? `${m.name}(${m.id})` : m.id;
};
const loadList = async () => {
listLoading.value = true;
try {
const res = await getAiProviderList();
if (res?.code === 200) list.value = res.data || [];
} finally {
listLoading.value = false;
}
};
const openAdd = () => {
dialogItem.value = null;
dialogVisible.value = true;
};
const openEdit = (item) => {
dialogItem.value = item;
dialogVisible.value = true;
};
const onSaved = async () => {
await loadList();
};
onMounted(loadList);
</script>
<style lang="less" scoped>
.aisettings {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.ai-guide {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 16px;
padding: 20px 24px;
border-radius: 12px;
background: linear-gradient(120deg, var(--el-color-primary-light-9), var(--el-fill-color-light));
border: 1px solid var(--el-color-primary-light-7);
&__text {
flex: 1;
min-width: 0;
h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
line-height: 1.8;
color: var(--el-text-color-secondary);
}
}
&__steps {
display: flex;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
.step {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 100px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
color: var(--el-text-color-regular);
i {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
font-style: normal;
font-size: 11px;
}
}
}
.ai-layout {
display: flex;
gap: 16px;
margin-top: 16px;
align-items: flex-start;
}
.ai-list {
width: 340px;
flex-shrink: 0;
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: var(--el-bg-color);
overflow: hidden;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
}
&__title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__body {
max-height: 560px;
overflow-y: auto;
padding: 8px;
}
}
.ai-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
}
&__main {
min-width: 0;
}
&__name {
display: flex;
align-items: center;
gap: 6px;
font-size: 13.5px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__meta {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.ai-detail {
flex: 1;
min-width: 0;
}
</style>
@@ -0,0 +1,964 @@
<template>
<div class="pg-page">
<!-- 左侧:会话列表 -->
<div class="pg-sidebar">
<div class="pg-sidebar__header">
<span>对话列表</span>
<el-button size="small" type="primary" plain @click="newChat">
<el-icon><Plus /></el-icon> 新对话
</el-button>
</div>
<div class="pg-sidebar__list">
<div
v-for="s in sessions"
:key="s.id"
class="pg-session"
:class="{ active: s.id === sessionId }"
@click="selectSession(s)"
>
<el-icon v-if="s.is_top === 1" class="pg-session__pin"><Top /></el-icon>
<el-input
v-if="renamingId === s.id"
v-model="renamingText"
size="small"
class="pg-session__input"
@click.stop
@keyup.enter="submitRename(s)"
@keyup.esc="renamingId = null"
@blur="submitRename(s)"
/>
<span v-else class="pg-session__title">{{ s.title || '新对话' }}</span>
<el-icon class="pg-session__act" title="重命名" @click.stop="startRename(s)"><Edit /></el-icon>
<el-icon class="pg-session__act" :title="s.is_top === 1 ? '取消置顶' : '置顶'" @click.stop="toggleTop(s)">
<Top />
</el-icon>
<el-icon class="pg-session__del" title="删除" @click.stop="removeSession(s)"><Delete /></el-icon>
</div>
<div v-if="!sessions.length" class="pg-sidebar__empty">暂无对话记录</div>
</div>
</div>
<el-card shadow="never" class="pg-card">
<template #header>
<div class="pg-header">
<span class="pg-title">Playground · 场景对话测试</span>
<div class="pg-selects">
<el-select v-model="providerId" placeholder="选择服务商配置" style="width: 220px" @change="onProviderChange">
<el-option
v-for="p in providers"
:key="p.id"
:label="`${p.name}(${apiTypeLabel(p.api_type)})`"
:value="p.id"
/>
</el-select>
<el-select v-model="model" placeholder="选择模型" style="width: 220px" :disabled="!modelOptions.length">
<el-option v-for="m in modelOptions" :key="m.id" :label="m.label" :value="m.id" />
</el-select>
</div>
</div>
</template>
<div class="pg-presets">
<el-button
v-for="s in scenes"
:key="s.label"
size="small"
text
@click="useScene(s)"
>{{ s.label }}</el-button>
</div>
<div class="pg-chat" ref="chatRef">
<div v-if="!messages.length" class="pg-empty">
选择服务商与模型后,输入场景问题开始测试对话效果
</div>
<div
v-for="(m, i) in messages"
:key="i"
class="pg-msg"
:class="m.role"
>
<div class="pg-msg__role">{{ m.role === 'user' ? '我' : 'AI' }}</div>
<div class="pg-msg__main">
<!-- 思考过程 -->
<div v-if="m.think" class="pg-think">
<button class="pg-think__toggle" @click="m._thinkOpen = !m._thinkOpen">
<el-icon><CaretRight v-if="!m._thinkOpen" /><CaretBottom v-else /></el-icon>
思考过程
</button>
<div v-show="m._thinkOpen" class="pg-think__body">{{ m.think }}</div>
</div>
<div class="pg-msg__bubble">
<div v-if="m.attachments?.length" class="pg-attachments">
<img
v-for="(a, j) in m.attachments"
:key="j"
:src="a.dataUrl"
:alt="a.name"
class="pg-attachments__img"
/>
</div>
<span v-if="m.content">{{ m.content }}</span>
<span v-else-if="m._streaming || m._pending" class="pg-caret"></span>
</div>
<div class="pg-msg__foot">
<span class="pg-msg__tokens">
≈{{ estimateTokens(m.content) }} tokens
<template v-if="m._toksPerSec"> · {{ m._toksPerSec }} toks/s</template>
</span>
<span class="pg-msg__actions">
<el-tooltip content="复制" placement="top">
<el-button link size="small" @click="copyMsg(m)"><el-icon><CopyDocument /></el-icon></el-button>
</el-tooltip>
<el-tooltip content="编辑" placement="top">
<el-button link size="small" @click="editMsg(i, m)"><el-icon><Edit /></el-icon></el-button>
</el-tooltip>
<el-tooltip v-if="m.role === 'user'" content="重新发送" placement="top">
<el-button link size="small" @click="resendMsg(i)"><el-icon><RefreshRight /></el-icon></el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button link size="small" type="danger" @click="removeMsg(i)"><el-icon><Delete /></el-icon></el-button>
</el-tooltip>
</span>
</div>
</div>
</div>
</div>
<div class="pg-input">
<el-tooltip content="上传附件(图片/PDF,直传模型不存储)" placement="top">
<el-button class="pg-attach-btn" @click="fileInputRef?.click()">
<el-icon><Paperclip /></el-icon>
</el-button>
</el-tooltip>
<input
ref="fileInputRef"
type="file"
accept="image/*,.pdf"
multiple
style="display: none"
@change="handleFiles"
/>
<div class="pg-input__main">
<div v-if="pendingFiles.length" class="pg-pending-files">
<div v-for="(f, i) in pendingFiles" :key="i" class="pg-pending-file">
<img v-if="f.preview" :src="f.preview" class="pg-pending-file__img" />
<span class="pg-pending-file__name">{{ f.name }}</span>
<el-icon class="pg-pending-file__del" @click="pendingFiles.splice(i, 1)"><Close /></el-icon>
</div>
</div>
<el-input
v-model="input"
type="textarea"
:rows="2"
resize="none"
placeholder="输入测试内容,Enter 发送,Ctrl+Enter 换行"
@keydown.enter.exact.prevent="send"
/>
</div>
<el-button type="primary" :loading="sending" :disabled="!canSend" class="pg-send-btn" @click="send">发送</el-button>
</div>
<div class="pg-total">本次会话 ≈ {{ totalTokens }} tokens</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { CopyDocument, Edit, RefreshRight, Delete, CaretRight, CaretBottom, Paperclip, Close, Plus, Top } from "@element-plus/icons-vue";
import {
getAiProviderList,
getAiChatSessions,
createAiChatSession,
getAiChatSessionMessages,
deleteAiChatSession,
updateAiChatSession,
} from "@/api/aisettings";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
const providers = ref([]);
const providerId = ref(null);
const model = ref("");
const messages = ref([]);
const input = ref("");
const sending = ref(false);
const chatRef = ref(null);
const fileInputRef = ref(null);
const pendingFiles = ref([]); // {name, mime, dataUrl, preview} 直传模型,不走系统上传接口
const sessions = ref([]);
const sessionId = ref(null);
const renamingId = ref(null);
const renamingText = ref("");
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const TYPE_LABELS = { deepseek: "DeepSeek", doubao: "豆包", codex: "Codex", custom: "自定义" };
const API_TYPES = { chat_completions: "Chat", responses: "Resp", anthropic: "Anthropic" };
const apiTypeLabel = (t) => TYPE_LABELS[t] || t;
const currentProvider = computed(() => providers.value.find((p) => p.id === providerId.value));
const modelOptions = computed(() =>
(currentProvider.value?.models || []).map((m) => ({
id: m.id || m,
label: m.name ? `${m.name}(${m.id})` : m.id || m,
}))
);
const canSend = computed(
() => providerId.value && model.value && (input.value.trim() || pendingFiles.value.length) && !sending.value
);
const totalTokens = computed(() =>
messages.value.reduce((sum, m) => sum + estimateTokens(m.content), 0)
);
const scenes = [
{ label: "通用问答", prompt: "用三句话介绍一下你自己" },
{ label: "代码生成", prompt: "用 Vue3 写一个防抖函数示例" },
{ label: "文案翻译", prompt: "把「欢迎使用云泽科技」翻译成英文和日文" },
{ label: "OCR 提示词", prompt: "请把图片中的文字完整识别出来,保持原有排版" },
];
const loadProviders = async () => {
const res = await getAiProviderList();
if (res?.code === 200) {
providers.value = (res.data || []).filter((p) => p.status === 1);
const def = providers.value.find((p) => p.is_default === 1) || providers.value[0];
if (def) {
providerId.value = def.id;
model.value = def.default_model || def.models?.[0]?.id || "";
}
}
};
const onProviderChange = () => {
const p = currentProvider.value;
model.value = p?.default_model || p?.models?.[0]?.id || "";
};
const loadSessions = async () => {
const res = await getAiChatSessions();
if (res?.code === 200) sessions.value = res.data || [];
};
const newChat = () => {
sessionId.value = null;
messages.value = [];
pendingFiles.value = [];
input.value = "";
};
/** 解析库中 user content:字符串或多模态数组 → {content, attachments} */
const parseStoredUserContent = (raw) => {
try {
const v = JSON.parse(raw);
if (typeof v === "string") return { content: v, attachments: [] };
if (Array.isArray(v)) {
let text = "";
const attachments = [];
for (const p of v) {
if (p?.type === "text") text += p.text || "";
if (p?.type === "image_url" && p.image_url?.url) {
attachments.push({ name: "图片", dataUrl: p.image_url.url });
}
}
return { content: text, attachments };
}
} catch {
/* 兼容纯文本 */
}
return { content: raw || "", attachments: [] };
};
const selectSession = async (s) => {
sessionId.value = s.id;
const res = await getAiChatSessionMessages(s.id);
if (res?.code !== 200) return;
messages.value = (res.data || []).map((m) => {
if (m.role === "user") {
const u = parseStoredUserContent(m.content);
return { role: "user", content: u.content, attachments: u.attachments };
}
return { role: m.role, content: m.content || "", think: m.think || "", _thinkOpen: false };
});
await scrollBottom();
};
const startRename = (s) => {
renamingId.value = s.id;
renamingText.value = s.title || "";
};
const submitRename = async (s) => {
if (renamingId.value !== s.id) return;
renamingId.value = null;
const title = renamingText.value.trim();
if (!title || title === s.title) return;
const res = await updateAiChatSession(s.id, { title });
if (res?.code === 200) loadSessions();
};
const toggleTop = async (s) => {
const res = await updateAiChatSession(s.id, { is_top: s.is_top === 1 ? 0 : 1 });
if (res?.code === 200) loadSessions();
};
const removeSession = async (s) => {
const res = await deleteAiChatSession(s.id);
if (res?.code === 200) {
ElMessage.success("已删除");
if (sessionId.value === s.id) newChat();
loadSessions();
}
};
const useScene = (s) => {
input.value = s.prompt;
};
const scrollBottom = async () => {
await nextTick();
if (chatRef.value) chatRef.value.scrollTop = chatRef.value.scrollHeight;
};
/** 简单 token 估算:CJK 字符按 1,ASCII 连续串按 4 字符 1 token */
const estimateTokens = (text) => {
if (!text) return 0;
let cjk = 0;
let ascii = 0;
for (const ch of text) {
if (/[⺀-鿿豈-﫿＀-￯]/.test(ch)) cjk++;
else ascii++;
}
return cjk + Math.ceil(ascii / 4);
};
const doStream = async (history, assistantMsg) => {
const token = localStorage.getItem("token");
const startedAt = Date.now();
const resp = await fetch(`${API_BASE}/platform/ai/chat/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider_id: providerId.value,
model: model.value,
messages: history,
session_id: sessionId.value || 0,
}),
});
if (!resp.ok || !resp.body) {
throw new Error(`请求失败(HTTP ${resp.status})`);
}
// 客户端打字机队列:上游分片可能被代理缓冲成大块,这里按帧匀速吐出
const pending = { text: "", think: "" };
assistantMsg._pending = true;
let finished = false;
const typeTimer = setInterval(() => {
let changed = false;
if (pending.think) {
const s = pending.think.slice(0, 2);
pending.think = pending.think.slice(2);
assistantMsg.think = (assistantMsg.think || "") + s;
assistantMsg._thinkOpen = true;
changed = true;
}
if (pending.text) {
const s = pending.text.slice(0, 2);
pending.text = pending.text.slice(2);
assistantMsg.content += s;
changed = true;
}
if (changed) scrollBottom();
if (finished && !pending.text && !pending.think) {
assistantMsg._pending = false;
clearInterval(typeTimer);
}
}, 16);
const finish = () => {
finished = true;
const secs = Math.max((Date.now() - startedAt) / 1000, 0.1);
assistantMsg._toksPerSec = (estimateTokens(assistantMsg.content) / secs).toFixed(1);
// 队列排空后由定时器自行清理并关闭 _pending;兜底 15s 强制清理
setTimeout(() => {
clearInterval(typeTimer);
assistantMsg._pending = false;
assistantMsg._streaming = false;
}, 15000);
};
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let ev;
try {
ev = JSON.parse(payload);
} catch {
continue;
}
if (ev.t === "delta") {
pending.text += ev.c;
} else if (ev.t === "think") {
pending.think += ev.c;
} else if (ev.t === "error") {
pending.text += `\n\n[错误] ${ev.c}`;
} else if (ev.t === "done") {
finish();
return;
}
}
}
finish();
};
const fileToDataURL = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = reject;
reader.readAsDataURL(file);
});
/** 图片压缩:最长边缩到 1600px,转 JPEG 0.85,显著减小 base64 体积 */
const compressImage = (dataUrl) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const max = 1600;
let { width, height } = img;
if (width <= max && height <= max) return resolve(dataUrl);
const scale = max / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/jpeg", 0.85));
};
img.onerror = () => resolve(dataUrl);
img.src = dataUrl;
});
const handleFiles = async (e) => {
const files = Array.from(e.target.files || []);
e.target.value = "";
for (const f of files) {
if (f.size > MAX_FILE_SIZE) {
ElMessage.warning(`${f.name} 超过 10MB,已跳过`);
continue;
}
let dataUrl = await fileToDataURL(f);
const isImage = f.type?.startsWith("image/");
if (isImage) dataUrl = await compressImage(dataUrl);
// 上游网关一般限制请求体 ~4-10MB,base64 约膨胀 33%,超过 5MB 原始文件风险较高
if (f.size > 5 * 1024 * 1024 && !isImage) {
ElMessage.warning(`${f.name} 较大(${(f.size / 1024 / 1024).toFixed(1)}MB),上游可能拒绝,请尝试压缩后重试`);
}
pendingFiles.value.push({
name: f.name,
mime: isImage ? "image/jpeg" : f.type || "application/octet-stream",
dataUrl,
preview: isImage ? dataUrl : "",
});
}
};
/** 把消息转成 OpenAI 风格 content:有附件时为多模态内容块数组 */
const buildApiContent = (m) => {
const text = (m.content || "").trim();
if (!m.attachments?.length) return text || " ";
const parts = [];
if (text) parts.push({ type: "text", text });
for (const a of m.attachments) {
parts.push({ type: "image_url", image_url: { url: a.dataUrl } });
}
return parts;
};
const send = async () => {
if (!canSend.value) return;
// 首次发送时创建会话
if (!sessionId.value) {
const res = await createAiChatSession({ provider_id: providerId.value, model: model.value });
if (res?.code === 200 && res.data?.id) {
sessionId.value = res.data.id;
loadSessions();
}
}
const text = input.value.trim();
const files = [...pendingFiles.value];
input.value = "";
pendingFiles.value = [];
messages.value.push({
role: "user",
content: text,
attachments: files.map((f) => ({ name: f.name, dataUrl: f.dataUrl })),
});
await scrollBottom();
sending.value = true;
messages.value.push({ role: "assistant", content: "", think: "", _streaming: true, _thinkOpen: false });
// 必须拿响应式代理对象再改,直接改原始对象不会触发视图更新
const assistantMsg = messages.value[messages.value.length - 1];
const history = messages.value
.filter((m) => !m._streaming && (m.content || m.attachments?.length))
.map((m) => ({ role: m.role, content: buildApiContent(m) }));
try {
await doStream(history, assistantMsg);
} catch (e) {
assistantMsg.content += `\n\n[调用失败] ${e.message || e}`;
assistantMsg._streaming = false;
ElMessage.error("调用失败");
} finally {
sending.value = false;
await scrollBottom();
}
};
const copyMsg = async (m) => {
const text = m.think ? `【思考】\n${m.think}\n\n【回复】\n${m.content}` : m.content;
try {
await navigator.clipboard.writeText(text);
ElMessage.success("已复制");
} catch {
ElMessage.warning("复制失败");
}
};
const editMsg = (i, m) => {
if (m.role !== "user") return;
input.value = m.content;
messages.value.splice(i, 1);
};
const resendMsg = async (i) => {
const m = messages.value[i];
if (!m || m.role !== "user") return;
input.value = m.content;
messages.value.splice(i);
await nextTick();
send();
};
const removeMsg = (i) => {
messages.value.splice(i, 1);
};
onMounted(() => {
loadProviders();
loadSessions();
});
</script>
<style lang="less" scoped>
.pg-page {
/* 顶部 = header 80px + right-main 上下 padding 40px + 多标签栏约 70px + 面板 padding 约 30px */
height: calc(100vh - 220px);
min-height: 480px;
display: flex;
gap: 12px;
}
.pg-sidebar {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: var(--el-bg-color);
overflow: hidden;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
background: var(--el-fill-color-lighter);
}
&__list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
&__empty {
padding: 24px 0;
text-align: center;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.pg-session {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 12.5px;
color: var(--el-text-color-regular);
transition: background 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
}
&.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
&__pin {
flex-shrink: 0;
font-size: 12px;
color: var(--el-color-warning);
}
&__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__input {
flex: 1;
min-width: 0;
}
&__act {
flex-shrink: 0;
opacity: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
.pg-session:hover & {
opacity: 1;
}
&:hover {
color: var(--el-color-primary);
}
}
&__del {
flex-shrink: 0;
opacity: 0;
color: var(--el-text-color-secondary);
.pg-session:hover & {
opacity: 1;
}
&:hover {
color: var(--el-color-danger);
}
}
}
.pg-card {
flex: 1;
display: flex;
flex-direction: column;
:deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
}
.pg-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.pg-title {
font-weight: 600;
font-size: 15px;
color: var(--el-text-color-primary);
}
.pg-selects {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.pg-presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.pg-chat {
flex: 1;
min-height: 280px;
overflow-y: auto;
padding: 14px;
border-radius: 8px;
background: var(--el-fill-color-lighter);
display: flex;
flex-direction: column;
gap: 14px;
}
.pg-empty {
margin: auto;
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.pg-msg {
display: flex;
gap: 10px;
align-items: flex-start;
&__role {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
&__main {
flex: 1;
min-width: 0;
}
&__bubble {
max-width: 85%;
padding: 10px 14px;
border-radius: 10px;
font-size: 13.5px;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
border: 1px solid var(--el-border-color-lighter);
}
&__foot {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
&__tokens {
font-size: 11.5px;
color: var(--el-text-color-placeholder);
}
&__actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.2s ease;
.el-button {
padding: 2px;
}
}
&:hover &__actions {
opacity: 1;
}
&.user {
flex-direction: row-reverse;
.pg-msg__main {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.pg-msg__bubble {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
}
.pg-msg__foot {
flex-direction: row-reverse;
}
}
}
.pg-think {
max-width: 85%;
margin-bottom: 6px;
&__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border: none;
border-radius: 100px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 12px;
cursor: pointer;
}
&__body {
margin-top: 6px;
padding: 10px 14px;
border-radius: 8px;
background: var(--el-fill-color-light);
border-left: 3px solid var(--el-color-warning);
font-size: 12.5px;
line-height: 1.8;
color: var(--el-text-color-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
}
.pg-caret {
display: inline-block;
width: 8px;
height: 15px;
background: var(--el-color-primary);
vertical-align: text-bottom;
animation: pgBlink 0.8s steps(2) infinite;
}
@keyframes pgBlink {
50% { opacity: 0; }
}
.pg-input {
display: flex;
gap: 10px;
margin-top: 12px;
align-items: flex-start;
.pg-attach-btn {
flex-shrink: 0;
align-self: center;
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
:deep(.el-textarea) {
flex: 1;
}
.pg-send-btn {
flex-shrink: 0;
align-self: center;
}
}
.pg-pending-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pg-pending-file {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px 2px 2px;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
font-size: 12px;
color: var(--el-text-color-regular);
max-width: 220px;
&__img {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 6px;
}
&__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__del {
flex-shrink: 0;
cursor: pointer;
color: var(--el-text-color-secondary);
&:hover {
color: var(--el-color-danger);
}
}
}
.pg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
&__img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
cursor: zoom-in;
}
}
.pg-total {
margin-top: 8px;
text-align: right;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,159 @@
<template>
<div class="login-settings" v-loading="loading">
<el-form ref="formRef" :model="form" label-width="140px" class="settings-form">
<el-card shadow="never" class="settings-card">
<template #header>
<div class="card-header">
<span>登录方式开关</span>
<span class="card-tip">
控制统一认证中心登录页(/auth/login)展示的登录方式,保存后立即生效
</span>
</div>
</template>
<el-form-item label="账号密码登录">
<el-switch v-model="form.login_pwd_enabled" active-value="1" inactive-value="0" />
<div class="form-tip">账号 + 密码登录。关闭后登录页不再显示该 tab,接口侧同步拒绝。</div>
</el-form-item>
<el-form-item label="短信登录">
<el-switch v-model="form.login_sms_enabled" active-value="1" inactive-value="0" />
<div class="form-tip">手机号 + 短信验证码登录。关闭后登录页不再显示该 tab。</div>
</el-form-item>
<el-form-item label="扫码登录">
<el-switch v-model="form.login_qr_enabled" active-value="1" inactive-value="0" />
<div class="form-tip">微信扫码登录(云泽小程序内确认)。关闭后登录页不再显示该 tab。</div>
</el-form-item>
<el-alert
v-if="allDisabled"
type="warning"
:closable="false"
show-icon
title="当前已关闭全部登录方式,统一认证中心将提示「登录功能已关闭」,所有用户无法登录,请谨慎操作。"
/>
</el-card>
</el-form>
<div class="footer-actions">
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存设置</el-button>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { getLoginBrand, saveLoginBrand } from "@/api/sitesettings";
const formRef = ref();
const loading = ref(false);
const submitting = ref(false);
const form = reactive({
login_pwd_enabled: "1",
login_sms_enabled: "1",
login_qr_enabled: "1",
});
// 服务端快照,用于“重置”丢弃未保存的改动
let snapshot = { ...form };
const allDisabled = computed(
() =>
form.login_pwd_enabled === "0" &&
form.login_sms_enabled === "0" &&
form.login_qr_enabled === "0"
);
const loadConfig = async () => {
loading.value = true;
try {
const res = await getLoginBrand();
if (res?.code === 200 && res.data) {
// 未配置的开关后端返回空串,按默认开启处理
form.login_pwd_enabled = res.data.login_pwd_enabled === "0" ? "0" : "1";
form.login_sms_enabled = res.data.login_sms_enabled === "0" ? "0" : "1";
form.login_qr_enabled = res.data.login_qr_enabled === "0" ? "0" : "1";
snapshot = { ...form };
} else {
ElMessage.error(res?.msg || "获取登录方式配置失败");
}
} catch (error) {
ElMessage.error(error?.response?.data?.msg || "获取登录方式配置失败");
} finally {
loading.value = false;
}
};
const handleSubmit = async () => {
if (submitting.value) return;
submitting.value = true;
try {
const res = await saveLoginBrand({
login_pwd_enabled: form.login_pwd_enabled,
login_sms_enabled: form.login_sms_enabled,
login_qr_enabled: form.login_qr_enabled,
});
if (res?.code === 200) {
ElMessage.success("保存成功,统一登录页已同步更新");
snapshot = { ...form };
} else {
ElMessage.error(res?.msg || "保存失败");
}
} catch (error) {
ElMessage.error(error?.response?.data?.msg || "保存失败");
} finally {
submitting.value = false;
}
};
const handleReset = () => {
Object.assign(form, snapshot);
ElMessage.info("已恢复到上次保存的配置");
};
onMounted(loadConfig);
</script>
<style lang="less" scoped>
.login-settings {
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-card {
margin-bottom: 12px;
}
.card-header {
display: flex;
align-items: baseline;
gap: 12px;
font-weight: 600;
font-size: 16px;
}
.card-tip {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.form-tip {
width: 100%;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
margin-top: 4px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -36,6 +36,11 @@
<el-tab-pane label="登录页设置" name="loginBrand">
<loginBrandSettings v-if="activeTab === 'loginBrand'" />
</el-tab-pane>
<!-- 登录方式开关(账号密码 / 短信 / 扫码) -->
<el-tab-pane label="登录方式" name="loginMethods">
<loginSettings v-if="activeTab === 'loginMethods'" />
</el-tab-pane>
</el-tabs>
</div>
</div>
@@ -47,6 +52,7 @@ import platformSettings from "./components/platformSettings.vue";
import storageSettings from "./components/storageSettings.vue";
import notificationSettings from "./components/notificationSettings.vue";
import loginBrandSettings from "./components/loginBrandSettings.vue";
import loginSettings from "./components/loginSettings.vue";
const activeTab = ref("platform");
</script>
@@ -1,21 +1,32 @@
<template>
<el-drawer v-model="visible" title="文章预览" size="60%">
<div class="preview" v-loading="!model">
<h1 class="preview__title">{{ model?.title || '-' }}</h1>
<div class="preview__header">
<el-image
v-if="model?.image"
:src="previewUrl(model.image)"
:preview-src-list="model?.image ? [previewUrl(model.image)] : []"
fit="cover"
preview-teleported
class="preview__cover"
alt="封面"
/>
<div class="preview__heading">
<h1 class="preview__title">{{ model?.title || '-' }}</h1>
<div class="preview__meta">
<span v-if="model?.cate">分类:{{ model.cate }}</span>
<span v-if="model?.author">作者:{{ model.author }}</span>
<span>发布时间:{{ formatDateTime(model?.publish_time || model?.publish_date) }}</span>
<span v-if="model?.views">浏览:{{ model.views }}</span>
<div class="preview__meta">
<span v-if="model?.cate">分类:{{ model.cate }}</span>
<span v-if="model?.author">作者:{{ model.author }}</span>
<span>发布时间:{{ formatDateTime(model?.publish_time || model?.publish_date) }}</span>
<span v-if="model?.views">浏览:{{ model.views }}</span>
</div>
</div>
</div>
<el-divider />
<p v-if="model?.desc" class="preview__desc">{{ model.desc }}</p>
<img v-if="model?.image" :src="previewUrl(model.image)" class="preview__cover" alt="封面" />
<div v-if="model?.is_trans === 1 && model?.transurl" class="preview__trans">
本文为转载内容,原文地址:
<el-link type="primary" :href="model.transurl" target="_blank">{{ model.transurl }}</el-link>
@@ -67,11 +78,37 @@ const previewUrl = (url) => {
.preview {
padding: 0 8px 40px;
&__header {
display: flex;
align-items: center;
gap: 24px;
}
&__cover {
flex: 0 0 200px;
width: 200px;
aspect-ratio: 25 / 14;
overflow: hidden;
border-radius: 8px;
cursor: zoom-in;
:deep(img) {
width: 100%;
height: 100%;
}
}
&__heading {
flex: 1;
min-width: 0;
}
&__title {
margin: 0 0 12px;
font-size: 24px;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
}
&__meta {
@@ -92,14 +129,6 @@ const previewUrl = (url) => {
color: var(--el-text-color-regular);
}
&__cover {
width: 100%;
max-height: 260px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 16px;
}
&__trans {
margin-bottom: 16px;
font-size: 13px;
@@ -107,32 +136,134 @@ const previewUrl = (url) => {
word-break: break-all;
}
/*
* 文章正文排版:与官网详情页 .blk-html 结构一致(标题左侧色条、引用块、
* 表格、代码块等),颜色使用主题变量,明暗模式下都能正常阅读。
*/
&__content {
font-size: 15px;
line-height: 1.9;
color: var(--el-text-color-primary);
padding: 8px 4px 20px;
font-size: 15.5px;
line-height: 2.05;
color: var(--el-text-color-regular);
word-break: break-word;
:deep(> :first-child) {
margin-top: 0;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4) {
margin: 34px 0 14px;
padding-left: 16px;
border-left: 3px solid #2f6bf3;
font-weight: 700;
line-height: 1.5;
color: var(--el-text-color-primary);
}
:deep(h1) { font-size: 24px; }
:deep(h2) { font-size: 22px; }
:deep(h3) { font-size: 18px; }
:deep(h4) { font-size: 16px; }
:deep(p) {
margin: 16px 0;
}
:deep(a) {
color: #2f6bf3;
text-decoration: underline;
&:hover {
opacity: 0.82;
}
}
:deep(strong) {
font-weight: 700;
color: var(--el-text-color-primary);
}
:deep(ul),
:deep(ol) {
margin: 16px 0;
padding-left: 26px;
li {
margin: 8px 0;
line-height: 1.95;
}
}
:deep(ul li) { list-style: disc; }
:deep(ol li) { list-style: decimal; }
:deep(img) {
max-width: 100%;
border-radius: 4px;
height: auto;
margin: 18px 0;
border-radius: 10px;
}
:deep(blockquote) {
margin: 22px 0;
padding: 22px 26px;
border-radius: 12px;
border-left: 3px solid #2f6bf3;
background: var(--el-color-primary-light-9);
color: var(--el-text-color-primary);
}
:deep(hr) {
margin: 30px 0;
border: 0;
border-top: 1px solid var(--el-border-color-lighter);
}
:deep(pre) {
padding: 12px 16px;
margin: 18px 0;
padding: 18px 22px;
border-radius: 10px;
background: var(--el-fill-color-light);
border-radius: 4px;
overflow-x: auto;
font-size: 13.5px;
line-height: 1.75;
}
:deep(code) {
padding: 2px 7px;
border-radius: 4px;
background: var(--el-fill-color-light);
font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 13.5px;
color: var(--el-text-color-primary);
}
:deep(pre code) {
padding: 0;
background: transparent;
}
:deep(table) {
width: 100%;
margin: 20px 0;
border-collapse: collapse;
font-size: 14px;
th,
td {
border: 1px solid var(--el-border-color);
padding: 8px 12px;
padding: 13px 18px;
border: 1px solid var(--el-border-color-lighter);
text-align: left;
line-height: 1.75;
}
th {
background: var(--el-fill-color-light);
font-weight: 600;
color: var(--el-text-color-regular);
}
}
}