main #107
@ -79,6 +79,9 @@ async def getbz_order(ns={}):
|
||||
# ns['total_discount_amount'] = total_discount_amount
|
||||
# ns['total_count'] = total_count[0]['total_count'] if total_count else 0
|
||||
|
||||
# 排除大模型订单
|
||||
sql += " AND og.is_big_model = 0"
|
||||
count_sql += " AND og.is_big_model = 0"
|
||||
|
||||
# 根据订单号搜索
|
||||
if ns.get('id'):
|
||||
|
||||
727
b/cntoai/chat.html
Normal file
727
b/cntoai/chat.html
Normal file
@ -0,0 +1,727 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>模型对话测试 · cntoai</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f0f4f8;
|
||||
--panel: #fff;
|
||||
--border: #e2e8f0;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--user-bg: #2563eb;
|
||||
--assistant-bg: #f1f5f9;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.layout { display: flex; height: 100vh; }
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-header h1 { font-size: 15px; margin-bottom: 4px; }
|
||||
.sidebar-header p { font-size: 11px; color: var(--muted); word-break: break-all; }
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; width: 100%; }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-ghost:hover { background: #f8fafc; color: var(--text); }
|
||||
.btn-danger { color: #dc2626; border-color: #fecaca; }
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.history-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.history-item:hover { background: #f1f5f9; }
|
||||
.history-item.active { background: #eff6ff; color: var(--primary); }
|
||||
.history-empty { padding: 16px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.toolbar {
|
||||
padding: 12px 20px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.toolbar label { font-size: 12px; color: var(--muted); margin-right: 4px; }
|
||||
.toolbar select, .toolbar input[type="text"] {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
min-width: 160px;
|
||||
}
|
||||
.toolbar .chk { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.notice {
|
||||
padding: 10px 20px;
|
||||
background: #fffbeb;
|
||||
border-bottom: 1px solid #fde68a;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.notice strong { display: block; margin-bottom: 4px; }
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.msg {
|
||||
max-width: 85%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--user-bg);
|
||||
color: #fff;
|
||||
}
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--assistant-bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.msg .role-tag {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.msg img.preview { max-width: 200px; border-radius: 8px; margin-top: 8px; display: block; }
|
||||
.composer {
|
||||
padding: 16px 20px;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.composer textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.composer-actions .left { display: flex; gap: 8px; align-items: center; }
|
||||
.pending-img {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pending-img img { max-height: 60px; border-radius: 6px; }
|
||||
.pending-img button {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #64748b;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.settings {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.settings summary { cursor: pointer; color: var(--muted); }
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.settings-grid input { width: 100%; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; }
|
||||
.auth-panel {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #f8fafc;
|
||||
}
|
||||
.auth-panel h2 { font-size: 13px; margin-bottom: 10px; color: var(--text); }
|
||||
.auth-panel label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.auth-panel label:first-of-type { margin-top: 0; }
|
||||
.auth-panel input {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.auth-panel .hint { font-size: 10px; color: var(--muted); margin-top: 10px; line-height: 1.4; }
|
||||
.status-bar {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
padding: 4px 20px 8px;
|
||||
}
|
||||
.loading { color: var(--primary); }
|
||||
.error { color: #dc2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="auth-panel">
|
||||
<h2>接口凭证(手动测试)</h2>
|
||||
<label for="cfgApiBase">Dspy 网关(本系统接口域名)</label>
|
||||
<input type="text" id="cfgApiBase" value="https://dev.opencomputing.cn" placeholder="https://dev.opencomputing.cn">
|
||||
|
||||
<label for="cfgApiUrl">api_url(模型 chat/completions 完整地址)</label>
|
||||
<input type="text" id="cfgApiUrl" value="https://ai.atvoe.com/llmage/v1/chat/completions" placeholder="https://.../v1/chat/completions">
|
||||
|
||||
<label for="cfgApiKey">api_key(Bearer 令牌,不含 Bearer 前缀)</label>
|
||||
<input type="text" id="cfgApiKey" placeholder="xGvvta0hnXPDDHIp7knfB" autocomplete="off">
|
||||
|
||||
<label for="cfgUserid">userid(会话归属用户,持久化接口必填)</label>
|
||||
<input type="text" id="cfgUserid" placeholder="users 表 id">
|
||||
|
||||
<p class="hint">填写后所有请求会携带 api_url、api_key、userid,可不登录 Cookie 测试。凭证仅保存在本机 localStorage。</p>
|
||||
<button type="button" class="btn btn-ghost" id="btnSaveAuth" style="margin-top:10px">保存凭证到本地</button>
|
||||
</div>
|
||||
<div class="sidebar-header">
|
||||
<h1>对话历史</h1>
|
||||
<button type="button" class="btn btn-primary" id="btnNewChat">开启新对话</button>
|
||||
<button type="button" class="btn btn-ghost btn-danger" id="btnDeleteSession" disabled>删除当前会话</button>
|
||||
</div>
|
||||
<div class="history-list" id="historyList">
|
||||
<div class="history-empty">填写 userid 后刷新</div>
|
||||
</div>
|
||||
<details class="settings">
|
||||
<summary>其它选项</summary>
|
||||
<div class="settings-grid">
|
||||
<label>model_id(可选,从文档表读 api_url)<input type="text" id="cfgModelId" placeholder="model_management 表 id"></label>
|
||||
<label class="chk"><input type="checkbox" id="cfgPersist" checked> 使用 chat_send(持久化多轮)</label>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="notice" id="noticeBox">
|
||||
<strong>测试说明</strong>
|
||||
左侧可手动填写 <code>api_url</code>、<code>api_key</code>、<code>userid</code> 直接联调;未填 userid 时持久化接口会失败。Dspy 网关默认 <code>https://dev.opencomputing.cn</code>,路径为 <code>/cntoai/*.dspy</code>。须已执行 <code>chat_tables.sql</code>。
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<label for="modelSelect">模型</label>
|
||||
<select id="modelSelect">
|
||||
<option value="">加载模型列表…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modelManual">或手动输入 model</label>
|
||||
<input type="text" id="modelManual" placeholder="qwen3.6-plus" value="qwen3.6-plus">
|
||||
</div>
|
||||
<label class="chk"><input type="checkbox" id="chkStream" checked> 流式(后端汇总后返回全文)</label>
|
||||
<button type="button" class="btn btn-ghost" id="btnRefreshHistory" style="width:auto;margin:0">刷新历史</button>
|
||||
</div>
|
||||
|
||||
<div class="messages" id="messages"></div>
|
||||
<div class="status-bar" id="statusBar"></div>
|
||||
|
||||
<div class="composer">
|
||||
<div class="pending-img" id="pendingImgWrap" style="display:none">
|
||||
<img id="pendingImg" alt="preview">
|
||||
<button type="button" id="btnClearImg" title="移除图片">×</button>
|
||||
</div>
|
||||
<textarea id="inputText" placeholder="输入消息,Ctrl+Enter 发送;可上传图片测试图文问答"></textarea>
|
||||
<div class="composer-actions">
|
||||
<div class="left">
|
||||
<label class="btn btn-ghost" style="width:auto;margin:0;cursor:pointer">
|
||||
图片<input type="file" id="fileImage" accept="image/*" hidden>
|
||||
</label>
|
||||
<span style="font-size:12px;color:var(--muted)">Ctrl+Enter 发送</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="btnSend" style="width:auto;min-width:100px">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
apiBase: 'https://dev.opencomputing.cn',
|
||||
sessionId: '',
|
||||
sending: false,
|
||||
imageBase64: '',
|
||||
imageMime: 'image/jpeg',
|
||||
models: [],
|
||||
};
|
||||
|
||||
function getApiBase() {
|
||||
return ($('cfgApiBase').value || 'https://dev.opencomputing.cn').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function getModel() {
|
||||
const sel = $('modelSelect').value;
|
||||
const manual = $('modelManual').value.trim();
|
||||
return manual || sel || '';
|
||||
}
|
||||
|
||||
function getModelId() {
|
||||
const opt = $('modelSelect').selectedOptions[0];
|
||||
return $('cfgModelId').value.trim() || (opt && opt.dataset.modelId) || '';
|
||||
}
|
||||
|
||||
/** 手动凭证,随每个 cntoai 接口请求传递 */
|
||||
function getAuthExtras() {
|
||||
const extras = {};
|
||||
const apiUrl = $('cfgApiUrl').value.trim();
|
||||
const apiKey = $('cfgApiKey').value.trim();
|
||||
const userid = $('cfgUserid').value.trim();
|
||||
if (apiUrl) extras.api_url = apiUrl;
|
||||
if (apiKey) extras.api_key = apiKey;
|
||||
if (userid) extras.userid = userid;
|
||||
return extras;
|
||||
}
|
||||
|
||||
const LS_AUTH = 'cntoai_chat_auth_v1';
|
||||
function saveAuthLocal() {
|
||||
localStorage.setItem(LS_AUTH, JSON.stringify({
|
||||
apiBase: $('cfgApiBase').value,
|
||||
apiUrl: $('cfgApiUrl').value,
|
||||
apiKey: $('cfgApiKey').value,
|
||||
userid: $('cfgUserid').value,
|
||||
}));
|
||||
setStatus('凭证已保存到浏览器本地');
|
||||
}
|
||||
function loadAuthLocal() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_AUTH);
|
||||
if (!raw) return;
|
||||
const o = JSON.parse(raw);
|
||||
if (o.apiBase) $('cfgApiBase').value = o.apiBase;
|
||||
if (o.apiUrl) $('cfgApiUrl').value = o.apiUrl;
|
||||
if (o.apiKey) $('cfgApiKey').value = o.apiKey;
|
||||
if (o.userid) $('cfgUserid').value = o.userid;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function buildUrl(path) {
|
||||
const base = getApiBase();
|
||||
const p = path.startsWith('/') ? path : '/' + path;
|
||||
return base + p;
|
||||
}
|
||||
|
||||
/** 调用 dspy:优先 POST JSON,失败时尝试 GET */
|
||||
async function callDspy(path, params, method) {
|
||||
const url = buildUrl(path);
|
||||
const body = { ...params, ...getAuthExtras() };
|
||||
const usePost = method === 'POST' || (method !== 'GET' && JSON.stringify(body).length > 1800);
|
||||
|
||||
const opts = {
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
};
|
||||
|
||||
let res;
|
||||
if (usePost) {
|
||||
opts.method = 'POST';
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
res = await fetch(url, opts);
|
||||
} else {
|
||||
const q = new URLSearchParams();
|
||||
Object.keys(body).forEach((k) => {
|
||||
const v = body[k];
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
q.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||||
}
|
||||
});
|
||||
res = await fetch(url + (q.toString() ? '?' + q.toString() : ''), {
|
||||
...opts,
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error('非 JSON 响应 HTTP ' + res.status + ': ' + text.slice(0, 200));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
const el = $('statusBar');
|
||||
el.textContent = msg;
|
||||
el.className = 'status-bar' + (isError ? ' error' : msg ? ' loading' : '');
|
||||
}
|
||||
|
||||
function renderMessages(list) {
|
||||
const box = $('messages');
|
||||
if (!list.length) {
|
||||
box.innerHTML = '<div class="msg assistant" style="align-self:center;max-width:100%"><div class="role-tag">提示</div>选择或新建对话后开始聊天</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = list.map((m) => {
|
||||
const role = m.role === 'user' ? 'user' : 'assistant';
|
||||
const label = role === 'user' ? '我' : '助手';
|
||||
const img = m.imagePreview ? '<img class="preview" src="' + m.imagePreview + '" alt="">' : '';
|
||||
return '<div class="msg ' + role + '"><div class="role-tag">' + label + '</div>' + escapeHtml(m.content) + img + '</div>';
|
||||
}).join('');
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
const sel = $('modelSelect');
|
||||
try {
|
||||
const res = await callDspy('/cntoai/model_management_customer_search.dspy', {
|
||||
page_size: 200,
|
||||
current_page: 1,
|
||||
}, 'GET');
|
||||
if (!res.status) {
|
||||
sel.innerHTML = '<option value="">加载失败: ' + (res.msg || '') + '</option>';
|
||||
return;
|
||||
}
|
||||
state.models = res.data.model_list || [];
|
||||
if (!state.models.length) {
|
||||
sel.innerHTML = '<option value="">无已上架模型,请手动输入</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = state.models.map((m) => {
|
||||
const name = m.model_name || m.display_name || m.id;
|
||||
return '<option value="' + escapeAttr(m.model_name || name) + '" data-model-id="' + escapeAttr(m.id) + '">' +
|
||||
escapeHtml((m.display_name || m.model_name) + ' (' + (m.provider || '') + ')') + '</option>';
|
||||
}).join('');
|
||||
if (state.models[0]) {
|
||||
$('modelManual').value = state.models[0].model_name || '';
|
||||
}
|
||||
} catch (e) {
|
||||
sel.innerHTML = '<option value="">请求异常</option>';
|
||||
setStatus('模型列表: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return String(s).replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const box = $('historyList');
|
||||
try {
|
||||
const res = await callDspy('/cntoai/chat_session_list.dspy', { page_size: 50 }, 'GET');
|
||||
if (!res.status) {
|
||||
box.innerHTML = '<div class="history-empty">' + escapeHtml(res.msg || '加载失败') + '</div>';
|
||||
return;
|
||||
}
|
||||
const sessions = res.data.sessions || [];
|
||||
if (!sessions.length) {
|
||||
box.innerHTML = '<div class="history-empty">暂无历史(发送消息后会出现在此)</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = sessions.map((s) => {
|
||||
const active = s.id === state.sessionId ? ' active' : '';
|
||||
const title = escapeHtml(s.title || '未命名');
|
||||
return '<div class="history-item' + active + '" data-id="' + escapeAttr(s.id) + '" title="' + title + '">' + title + '</div>';
|
||||
}).join('');
|
||||
box.querySelectorAll('.history-item').forEach((el) => {
|
||||
el.addEventListener('click', () => loadSession(el.dataset.id));
|
||||
});
|
||||
} catch (e) {
|
||||
box.innerHTML = '<div class="history-empty">' + escapeHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSession(sessionId) {
|
||||
state.sessionId = sessionId;
|
||||
$('btnDeleteSession').disabled = !sessionId;
|
||||
setStatus('加载会话…');
|
||||
try {
|
||||
const res = await callDspy('/cntoai/chat_session_messages.dspy', { session_id: sessionId }, 'GET');
|
||||
if (!res.status) {
|
||||
setStatus(res.msg || '加载失败', true);
|
||||
return;
|
||||
}
|
||||
const session = res.data.session || {};
|
||||
if (session.model) {
|
||||
$('modelManual').value = session.model;
|
||||
const sel = $('modelSelect');
|
||||
for (let i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === session.model) {
|
||||
sel.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const msgs = (res.data.messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
}));
|
||||
syncUiMessages(msgs);
|
||||
renderMessages(uiMessages);
|
||||
await loadHistory();
|
||||
setStatus('');
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession() {
|
||||
if (!state.sessionId || !confirm('确定删除当前会话?')) return;
|
||||
try {
|
||||
const res = await callDspy('/cntoai/chat_session_delete.dspy', { session_id: state.sessionId }, 'GET');
|
||||
if (res.status) {
|
||||
state.sessionId = '';
|
||||
$('btnDeleteSession').disabled = true;
|
||||
syncUiMessages([]);
|
||||
renderMessages([]);
|
||||
await loadHistory();
|
||||
setStatus('已删除');
|
||||
} else {
|
||||
setStatus(res.msg || '删除失败', true);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
state.sessionId = '';
|
||||
$('btnDeleteSession').disabled = true;
|
||||
syncUiMessages([]);
|
||||
renderMessages([]);
|
||||
setStatus('新对话');
|
||||
}
|
||||
|
||||
let uiMessages = [];
|
||||
|
||||
function syncUiMessages(list) {
|
||||
uiMessages = list.map((m) => ({ role: m.role, content: m.content || '' }));
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = $('inputText').value.trim();
|
||||
const model = getModel();
|
||||
if (!model) {
|
||||
alert('请选择或输入模型名称');
|
||||
return;
|
||||
}
|
||||
if (!text && !state.imageBase64) {
|
||||
alert('请输入文本或上传图片');
|
||||
return;
|
||||
}
|
||||
if (state.sending) return;
|
||||
|
||||
const auth = getAuthExtras();
|
||||
const persist = $('cfgPersist').checked;
|
||||
if (persist && !auth.userid) {
|
||||
alert('使用 chat_send 持久化时,请在左侧填写 userid');
|
||||
return;
|
||||
}
|
||||
if (!auth.api_key) {
|
||||
if (!confirm('未填写 api_key,将依赖服务端配置或登录用户 Key,是否继续?')) return;
|
||||
}
|
||||
|
||||
const userContent = text || '[图片消息]';
|
||||
uiMessages.push({
|
||||
role: 'user',
|
||||
content: userContent,
|
||||
imagePreview: state.imageBase64 ? ('data:' + state.imageMime + ';base64,' + state.imageBase64) : '',
|
||||
});
|
||||
renderMessages(uiMessages);
|
||||
$('inputText').value = '';
|
||||
const imgB64 = state.imageBase64;
|
||||
const imgMime = state.imageMime;
|
||||
clearImage();
|
||||
state.sending = true;
|
||||
$('btnSend').disabled = true;
|
||||
setStatus('请求中…');
|
||||
|
||||
const payload = {
|
||||
model,
|
||||
message: text,
|
||||
stream: $('chkStream').checked,
|
||||
model_id: getModelId() || undefined,
|
||||
...getAuthExtras(),
|
||||
};
|
||||
if (state.sessionId) payload.session_id = state.sessionId;
|
||||
if (imgB64) {
|
||||
payload.image_base64 = imgB64;
|
||||
payload.image_mime = imgMime;
|
||||
}
|
||||
|
||||
const path = persist ? '/cntoai/chat_send.dspy' : '/cntoai/llm_chat_completions.dspy';
|
||||
|
||||
if (!persist) {
|
||||
payload.messages = uiMessages.slice(0, -1).map((m) => ({ role: m.role, content: m.content }));
|
||||
payload.message = text || '请描述这张图片';
|
||||
if (imgB64) {
|
||||
payload.image_base64 = imgB64;
|
||||
payload.image_mime = imgMime;
|
||||
}
|
||||
delete payload.session_id;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await callDspy(path, payload, 'POST');
|
||||
if (!res.status) {
|
||||
setStatus(res.msg || '请求失败', true);
|
||||
uiMessages.pop();
|
||||
renderMessages(uiMessages);
|
||||
return;
|
||||
}
|
||||
const reply = persist ? (res.data && res.data.reply) : (res.data && res.data.reply);
|
||||
if (persist && res.data && res.data.session_id) {
|
||||
state.sessionId = res.data.session_id;
|
||||
$('btnDeleteSession').disabled = false;
|
||||
}
|
||||
uiMessages.push({ role: 'assistant', content: reply || '(空回复)' });
|
||||
renderMessages(uiMessages);
|
||||
await loadHistory();
|
||||
setStatus('完成');
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
uiMessages.pop();
|
||||
renderMessages(uiMessages);
|
||||
} finally {
|
||||
state.sending = false;
|
||||
$('btnSend').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
state.imageBase64 = '';
|
||||
$('pendingImgWrap').style.display = 'none';
|
||||
$('fileImage').value = '';
|
||||
}
|
||||
|
||||
$('fileImage').addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
state.imageMime = file.type || 'image/jpeg';
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result;
|
||||
state.imageBase64 = String(dataUrl).split(',')[1] || '';
|
||||
$('pendingImg').src = dataUrl;
|
||||
$('pendingImgWrap').style.display = 'inline-block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
$('btnClearImg').addEventListener('click', clearImage);
|
||||
$('btnSend').addEventListener('click', sendMessage);
|
||||
$('btnNewChat').addEventListener('click', newChat);
|
||||
$('btnDeleteSession').addEventListener('click', deleteSession);
|
||||
$('btnRefreshHistory').addEventListener('click', () => {
|
||||
if (!$('cfgUserid').value.trim() && $('cfgPersist').checked) {
|
||||
alert('持久化接口需要填写 userid');
|
||||
return;
|
||||
}
|
||||
loadHistory();
|
||||
});
|
||||
$('inputText').addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'Enter') sendMessage();
|
||||
});
|
||||
$('modelSelect').addEventListener('change', () => {
|
||||
const v = $('modelSelect').value;
|
||||
if (v) $('modelManual').value = v;
|
||||
const opt = $('modelSelect').selectedOptions[0];
|
||||
if (opt && opt.dataset.modelId) $('cfgModelId').value = opt.dataset.modelId;
|
||||
});
|
||||
|
||||
$('btnSaveAuth').addEventListener('click', saveAuthLocal);
|
||||
$('cfgUserid').addEventListener('change', loadHistory);
|
||||
$('cfgApiBase').addEventListener('change', () => {
|
||||
loadModels();
|
||||
loadHistory();
|
||||
});
|
||||
|
||||
function init() {
|
||||
loadAuthLocal();
|
||||
renderMessages([]);
|
||||
loadModels();
|
||||
if ($('cfgUserid').value.trim()) loadHistory();
|
||||
else {
|
||||
$('historyList').innerHTML = '<div class="history-empty">请填写 userid 后点「刷新历史」</div>';
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
177
b/cntoai/chat_send.dspy
Normal file
177
b/cntoai/chat_send.dspy
Normal file
@ -0,0 +1,177 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _parse_bool(value, default=True):
|
||||
if value is None or value == '':
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _title_from_message(ns):
|
||||
text = ns.get('message') or ns.get('text') or ''
|
||||
text = str(text).strip().replace('\n', ' ')
|
||||
if not text:
|
||||
return '新对话'
|
||||
return text[:30] + ('...' if len(text) > 30 else '')
|
||||
|
||||
|
||||
def _build_user_content(ns):
|
||||
text_parts = []
|
||||
if ns.get('message'):
|
||||
text_parts.append(str(ns.get('message')))
|
||||
if ns.get('text'):
|
||||
text_parts.append(str(ns.get('text')))
|
||||
if ns.get('document_text'):
|
||||
text_parts.append(str(ns.get('document_text')))
|
||||
|
||||
parts = []
|
||||
merged_text = '\n'.join([p for p in text_parts if p]).strip()
|
||||
if merged_text:
|
||||
parts.append({'type': 'text', 'text': merged_text})
|
||||
if ns.get('image_url'):
|
||||
parts.append({'type': 'image_url', 'image_url': {'url': ns.get('image_url')}})
|
||||
if ns.get('image_base64'):
|
||||
mime = ns.get('image_mime') or 'image/jpeg'
|
||||
b64 = ns.get('image_base64')
|
||||
if not str(b64).startswith('data:'):
|
||||
b64 = 'data:%s;base64,%s' % (mime, b64)
|
||||
parts.append({'type': 'image_url', 'image_url': {'url': b64}})
|
||||
if ns.get('document_url'):
|
||||
parts.append({'type': 'file', 'file': {'file_url': ns.get('document_url')}})
|
||||
if not parts:
|
||||
return ''
|
||||
if len(parts) == 1 and parts[0]['type'] == 'text':
|
||||
return parts[0]['text']
|
||||
return parts
|
||||
|
||||
|
||||
async def _load_session_messages(sor, session_id):
|
||||
sql = """
|
||||
SELECT role, content, content_type
|
||||
FROM chat_message
|
||||
WHERE session_id = '%s'
|
||||
ORDER BY created_at ASC;
|
||||
""" % _escape(session_id)
|
||||
rows = await sor.sqlExe(sql, {})
|
||||
messages = []
|
||||
for row in rows:
|
||||
content = row.get('content') or ''
|
||||
if row.get('content_type') == 'mixed':
|
||||
import json
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except Exception:
|
||||
pass
|
||||
messages.append({'role': row['role'], 'content': content})
|
||||
return messages
|
||||
|
||||
|
||||
async def chat_send(ns={}):
|
||||
"""
|
||||
发送消息并保存多轮对话(需先执行 chat_tables.sql)。
|
||||
|
||||
参数:model, message, stream(默认true), session_id,
|
||||
image_url, image_base64, document_url, document_text,
|
||||
with_chunks(true时返回上游 SSE 分片列表,便于确认流式)
|
||||
|
||||
说明:本接口(chat_send.dspy)为 JSON 一次性返回。
|
||||
需要浏览器端实时流式请调用 chat_send_stream.dspy(SSE)。
|
||||
"""
|
||||
import json
|
||||
import traceback
|
||||
|
||||
# model = ns.get('model')
|
||||
model = 'deepseek-v4-pro'
|
||||
if not model:
|
||||
return {'status': False, 'msg': 'model is required'}
|
||||
|
||||
userid = ns.get('userid') or await get_user()
|
||||
if not userid:
|
||||
return {'status': False, 'msg': '未找到用户'}
|
||||
|
||||
user_content = _build_user_content(ns)
|
||||
if not user_content:
|
||||
return {'status': False, 'msg': '请输入文本,或提供图片/文档参数'}
|
||||
|
||||
content_type = 'mixed' if isinstance(user_content, list) else 'text'
|
||||
store_content = json.dumps(user_content, ensure_ascii=False) if content_type == 'mixed' else str(user_content)
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
session_id = ns.get('session_id')
|
||||
if not session_id:
|
||||
session_id = uuid()
|
||||
await sor.C('chat_session', {
|
||||
'id': session_id,
|
||||
'userid': userid,
|
||||
'model': model,
|
||||
'title': _title_from_message(ns),
|
||||
})
|
||||
else:
|
||||
sessions = await sor.R('chat_session', {'id': session_id, 'userid': userid})
|
||||
if not sessions:
|
||||
return {'status': False, 'msg': '会话不存在'}
|
||||
|
||||
await sor.C('chat_message', {
|
||||
'id': uuid(),
|
||||
'session_id': session_id,
|
||||
'role': 'user',
|
||||
'content': store_content,
|
||||
'content_type': content_type,
|
||||
})
|
||||
|
||||
history = await _load_session_messages(sor, session_id)
|
||||
stream_val = _parse_bool(ns.get('stream'), True)
|
||||
chat_result = await path_call('llm_chat_completions.dspy', {
|
||||
'model': model,
|
||||
'messages': history,
|
||||
'stream': stream_val,
|
||||
'userid': userid,
|
||||
'api_url': ns.get('api_url'),
|
||||
'api_key': ns.get('api_key'),
|
||||
'model_id': ns.get('model_id'),
|
||||
'with_chunks': ns.get('with_chunks', True),
|
||||
})
|
||||
if not chat_result.get('status'):
|
||||
return chat_result
|
||||
|
||||
reply = chat_result['data']['reply']
|
||||
chunks = chat_result['data'].get('chunks') or []
|
||||
chunk_count = chat_result['data'].get('chunk_count', 0)
|
||||
await sor.C('chat_message', {
|
||||
'id': uuid(),
|
||||
'session_id': session_id,
|
||||
'role': 'assistant',
|
||||
'content': reply,
|
||||
'content_type': 'text',
|
||||
})
|
||||
await sor.sqlExe(
|
||||
"UPDATE chat_session SET updated_at = NOW() WHERE id = '%s';"
|
||||
% _escape(session_id),
|
||||
{},
|
||||
)
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'send success',
|
||||
'data': {
|
||||
'session_id': session_id,
|
||||
'reply': reply,
|
||||
'model': model,
|
||||
'stream': stream_val,
|
||||
'chunk_count': chunk_count,
|
||||
'chunks': chunks if ns.get('with_chunks', True) else None,
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
return {'status': False, 'msg': 'send failed, %s' % traceback.format_exc()}
|
||||
|
||||
|
||||
ret = await chat_send(params_kw)
|
||||
return ret
|
||||
311
b/cntoai/chat_send_stream.dspy
Normal file
311
b/cntoai/chat_send_stream.dspy
Normal file
@ -0,0 +1,311 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _parse_bool(value, default=True):
|
||||
if value is None or value == '':
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _title_from_message(ns):
|
||||
text = ns.get('message') or ns.get('text') or ''
|
||||
text = str(text).strip().replace('\n', ' ')
|
||||
if not text:
|
||||
return '新对话'
|
||||
return text[:30] + ('...' if len(text) > 30 else '')
|
||||
|
||||
|
||||
def _build_user_content(ns):
|
||||
text_parts = []
|
||||
if ns.get('message'):
|
||||
text_parts.append(str(ns.get('message')))
|
||||
if ns.get('text'):
|
||||
text_parts.append(str(ns.get('text')))
|
||||
if ns.get('document_text'):
|
||||
text_parts.append(str(ns.get('document_text')))
|
||||
|
||||
parts = []
|
||||
merged_text = '\n'.join([p for p in text_parts if p]).strip()
|
||||
if merged_text:
|
||||
parts.append({'type': 'text', 'text': merged_text})
|
||||
if ns.get('image_url'):
|
||||
parts.append({'type': 'image_url', 'image_url': {'url': ns.get('image_url')}})
|
||||
if ns.get('image_base64'):
|
||||
mime = ns.get('image_mime') or 'image/jpeg'
|
||||
b64 = ns.get('image_base64')
|
||||
if not str(b64).startswith('data:'):
|
||||
b64 = 'data:%s;base64,%s' % (mime, b64)
|
||||
parts.append({'type': 'image_url', 'image_url': {'url': b64}})
|
||||
if ns.get('document_url'):
|
||||
parts.append({'type': 'file', 'file': {'file_url': ns.get('document_url')}})
|
||||
if not parts:
|
||||
return ''
|
||||
if len(parts) == 1 and parts[0]['type'] == 'text':
|
||||
return parts[0]['text']
|
||||
return parts
|
||||
|
||||
|
||||
async def _load_session_messages(sor, session_id):
|
||||
sql = """
|
||||
SELECT role, content, content_type
|
||||
FROM chat_message
|
||||
WHERE session_id = '%s'
|
||||
ORDER BY created_at ASC;
|
||||
""" % _escape(session_id)
|
||||
rows = await sor.sqlExe(sql, {})
|
||||
messages = []
|
||||
for row in rows:
|
||||
content = row.get('content') or ''
|
||||
if row.get('content_type') == 'mixed':
|
||||
import json
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except Exception:
|
||||
pass
|
||||
messages.append({'role': row['role'], 'content': content})
|
||||
return messages
|
||||
|
||||
|
||||
async def _resolve_chat_config(ns, sor):
|
||||
# api_url = ns.get('api_url')
|
||||
# api_key = ns.get('api_key')
|
||||
api_url = 'https://api.deepseek.com/chat/completions'
|
||||
api_key = 'sk-c22d6573e85a4d3fa8ab932386cf2909'
|
||||
if not api_url and ns.get('model_id'):
|
||||
doc_rows = await sor.sqlExe(
|
||||
"SELECT api_url FROM model_api_doc WHERE model_id = '%s' LIMIT 1;"
|
||||
% _escape(ns.get('model_id')),
|
||||
{},
|
||||
)
|
||||
if doc_rows and doc_rows[0].get('api_url'):
|
||||
api_url = doc_rows[0]['api_url']
|
||||
if not str(api_url).endswith('/chat/completions'):
|
||||
api_url = str(api_url).rstrip('/') + '/chat/completions'
|
||||
if not api_url:
|
||||
param_rows = await sor.R('params', {'pname': 'cntoai_llm_chat_url'})
|
||||
if param_rows:
|
||||
api_url = param_rows[0]['pvalue']
|
||||
else:
|
||||
domain_rows = await sor.R('params', {'pname': 'cntoai_domain'})
|
||||
if domain_rows:
|
||||
api_url = domain_rows[0]['pvalue'].rstrip('/') + '/llmage/v1/chat/completions'
|
||||
else:
|
||||
api_url = 'https://ai.atvoe.com/llmage/v1/chat/completions'
|
||||
if not api_key:
|
||||
userid = ns.get('userid') or await get_user()
|
||||
if userid:
|
||||
action = ns.get('apikey_action') or 'user_self_create'
|
||||
keys = await sor.R('user_api_keys', {'userid': userid, 'action': action})
|
||||
if not keys:
|
||||
keys = await sor.R('user_api_keys', {'userid': userid, 'action': 'sync'})
|
||||
if keys:
|
||||
api_key = keys[0].get('opc_apikey')
|
||||
if not api_key:
|
||||
key_rows = await sor.R('params', {'pname': 'cntoai_llm_api_key'})
|
||||
if key_rows:
|
||||
api_key = key_rows[0]['pvalue']
|
||||
return api_url, api_key
|
||||
|
||||
|
||||
def _extract_stream_piece(payload):
|
||||
choice = (payload.get('choices') or [{}])[0]
|
||||
delta = choice.get('delta') or {}
|
||||
message = choice.get('message') or {}
|
||||
piece = (
|
||||
delta.get('content')
|
||||
or delta.get('reasoning_content')
|
||||
or message.get('content')
|
||||
or choice.get('text')
|
||||
or payload.get('content')
|
||||
or ''
|
||||
)
|
||||
if piece is None:
|
||||
return ''
|
||||
return str(piece)
|
||||
|
||||
|
||||
def _sse_event(obj):
|
||||
import json
|
||||
return 'data: %s\n\n' % json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
|
||||
async def _iter_upstream_stream(api_url, api_key, payload):
|
||||
import aiohttp
|
||||
import json
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer %s' % api_key,
|
||||
}
|
||||
payload = dict(payload)
|
||||
payload['stream'] = True
|
||||
|
||||
buffer = ''
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=600)) as session:
|
||||
async with session.post(api_url, headers=headers, json=payload) as response:
|
||||
if response.status != 200:
|
||||
err_text = await response.text()
|
||||
yield {'type': 'error', 'msg': 'HTTP %s: %s' % (response.status, err_text[:500])}
|
||||
return
|
||||
|
||||
async for raw in response.content:
|
||||
buffer += raw.decode('utf-8', errors='ignore')
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line or line.startswith(':') or not line.startswith('data:'):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == '[DONE]':
|
||||
return
|
||||
try:
|
||||
payload_obj = json.loads(data)
|
||||
piece = _extract_stream_piece(payload_obj)
|
||||
if piece:
|
||||
yield {'type': 'content', 'content': piece}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
tail = buffer.strip()
|
||||
if tail:
|
||||
try:
|
||||
body = json.loads(tail)
|
||||
choice = (body.get('choices') or [{}])[0]
|
||||
msg = choice.get('message') or {}
|
||||
piece = msg.get('content') or choice.get('text') or ''
|
||||
if piece:
|
||||
yield {'type': 'content', 'content': str(piece)}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def inference_generator(request, params_kw=None, **kw):
|
||||
"""
|
||||
流式 chat_send:先存 user 消息,SSE 推送 assistant 片段,结束后存库。
|
||||
|
||||
SSE 事件:
|
||||
{"type":"meta","session_id":"...","model":"..."}
|
||||
{"type":"content","content":"片段"}
|
||||
{"type":"done","session_id":"...","reply":"完整文本","model":"..."}
|
||||
{"type":"error","msg":"..."}
|
||||
"""
|
||||
import json
|
||||
import traceback
|
||||
|
||||
ns = params_kw or {}
|
||||
# model = ns.get('model')
|
||||
model = 'deepseek-v4-pro'
|
||||
if not model:
|
||||
yield _sse_event({'type': 'error', 'msg': 'model is required'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
userid = ns.get('userid') or await get_user()
|
||||
if not userid:
|
||||
yield _sse_event({'type': 'error', 'msg': '未找到用户'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
user_content = _build_user_content(ns)
|
||||
if not user_content:
|
||||
yield _sse_event({'type': 'error', 'msg': '请输入文本,或提供图片/文档参数'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
content_type = 'mixed' if isinstance(user_content, list) else 'text'
|
||||
store_content = json.dumps(user_content, ensure_ascii=False) if content_type == 'mixed' else str(user_content)
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
session_id = ns.get('session_id')
|
||||
if not session_id:
|
||||
session_id = uuid()
|
||||
await sor.C('chat_session', {
|
||||
'id': session_id,
|
||||
'userid': userid,
|
||||
'model': model,
|
||||
'title': _title_from_message(ns),
|
||||
})
|
||||
else:
|
||||
sessions = await sor.R('chat_session', {'id': session_id, 'userid': userid})
|
||||
if not sessions:
|
||||
yield _sse_event({'type': 'error', 'msg': '会话不存在'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
await sor.C('chat_message', {
|
||||
'id': uuid(),
|
||||
'session_id': session_id,
|
||||
'role': 'user',
|
||||
'content': store_content,
|
||||
'content_type': content_type,
|
||||
})
|
||||
|
||||
history = await _load_session_messages(sor, session_id)
|
||||
api_url, api_key = await _resolve_chat_config(ns, sor)
|
||||
if not api_key:
|
||||
yield _sse_event({'type': 'error', 'msg': '未找到 API Key'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
yield _sse_event({
|
||||
'type': 'meta',
|
||||
'session_id': session_id,
|
||||
'model': model,
|
||||
'stream': True,
|
||||
})
|
||||
|
||||
parts = []
|
||||
async for evt in _iter_upstream_stream(api_url, api_key, {
|
||||
'model': model,
|
||||
'messages': history,
|
||||
}):
|
||||
if evt.get('type') == 'error':
|
||||
yield _sse_event(evt)
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
if evt.get('type') == 'content':
|
||||
parts.append(evt['content'])
|
||||
yield _sse_event(evt)
|
||||
|
||||
reply = ''.join(parts)
|
||||
await sor.C('chat_message', {
|
||||
'id': uuid(),
|
||||
'session_id': session_id,
|
||||
'role': 'assistant',
|
||||
'content': reply,
|
||||
'content_type': 'text',
|
||||
})
|
||||
await sor.sqlExe(
|
||||
"UPDATE chat_session SET updated_at = NOW() WHERE id = '%s';"
|
||||
% _escape(session_id),
|
||||
{},
|
||||
)
|
||||
|
||||
yield _sse_event({
|
||||
'type': 'done',
|
||||
'session_id': session_id,
|
||||
'reply': reply,
|
||||
'model': model,
|
||||
})
|
||||
yield 'data: [DONE]\n\n'
|
||||
except Exception:
|
||||
yield _sse_event({'type': 'error', 'msg': traceback.format_exc()})
|
||||
yield 'data: [DONE]\n\n'
|
||||
|
||||
|
||||
async def inference(request, *args, params_kw=None, **kw):
|
||||
from functools import partial
|
||||
env = request._run_ns.copy()
|
||||
f = partial(inference_generator, request, params_kw=params_kw, **kw)
|
||||
return await env.stream_response(request, f, content_type='text/event-stream')
|
||||
|
||||
|
||||
ret = await inference(request, params_kw=params_kw)
|
||||
return ret
|
||||
39
b/cntoai/chat_session_delete.dspy
Normal file
39
b/cntoai/chat_session_delete.dspy
Normal file
@ -0,0 +1,39 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
async def chat_session_delete(ns={}):
|
||||
"""删除会话及其全部消息"""
|
||||
session_id = ns.get('session_id')
|
||||
if not session_id:
|
||||
return {'status': False, 'msg': 'session_id is required'}
|
||||
|
||||
userid = ns.get('userid') or await get_user()
|
||||
if not userid:
|
||||
return {'status': False, 'msg': '未找到用户'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
sessions = await sor.R('chat_session', {'id': session_id, 'userid': userid})
|
||||
if not sessions:
|
||||
return {'status': False, 'msg': '会话不存在'}
|
||||
|
||||
await sor.sqlExe(
|
||||
"DELETE FROM chat_message WHERE session_id = '%s';" % _escape(session_id),
|
||||
{},
|
||||
)
|
||||
await sor.sqlExe(
|
||||
"DELETE FROM chat_session WHERE id = '%s' AND userid = '%s';"
|
||||
% (_escape(session_id), _escape(userid)),
|
||||
{},
|
||||
)
|
||||
return {'status': True, 'msg': 'delete success'}
|
||||
except Exception as e:
|
||||
return {'status': False, 'msg': 'delete failed, %s' % str(e)}
|
||||
|
||||
|
||||
ret = await chat_session_delete(params_kw)
|
||||
return ret
|
||||
50
b/cntoai/chat_session_list.dspy
Normal file
50
b/cntoai/chat_session_list.dspy
Normal file
@ -0,0 +1,50 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
async def chat_session_list(ns={}):
|
||||
"""当前用户的对话会话列表(左侧栏历史)"""
|
||||
userid = ns.get('userid') or await get_user()
|
||||
if not userid:
|
||||
return {'status': False, 'msg': '未找到用户'}
|
||||
|
||||
page_size = int(ns.get('page_size')) if ns.get('page_size') else 100
|
||||
current_page = int(ns.get('current_page')) if ns.get('current_page') else 1
|
||||
offset = (current_page - 1) * page_size
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
count_sql = """
|
||||
SELECT COUNT(*) AS total_count FROM chat_session
|
||||
WHERE userid = '%s';
|
||||
""" % _escape(userid)
|
||||
total = (await sor.sqlExe(count_sql, {}))[0]['total_count']
|
||||
|
||||
find_sql = """
|
||||
SELECT id, model, title, created_at, updated_at
|
||||
FROM chat_session
|
||||
WHERE userid = '%s'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT %s OFFSET %s;
|
||||
""" % (_escape(userid), page_size, offset)
|
||||
sessions = await sor.sqlExe(find_sql, {})
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'list success',
|
||||
'data': {
|
||||
'total_count': total,
|
||||
'page_size': page_size,
|
||||
'current_page': current_page,
|
||||
'sessions': sessions,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': False, 'msg': 'list failed, %s' % str(e)}
|
||||
|
||||
|
||||
ret = await chat_session_list(params_kw)
|
||||
return ret
|
||||
66
b/cntoai/chat_session_messages.dspy
Normal file
66
b/cntoai/chat_session_messages.dspy
Normal file
@ -0,0 +1,66 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
async def chat_session_messages(ns={}):
|
||||
"""获取某次会话的全部消息"""
|
||||
session_id = ns.get('session_id')
|
||||
if not session_id:
|
||||
return {'status': False, 'msg': 'session_id is required'}
|
||||
|
||||
userid = ns.get('userid') or await get_user()
|
||||
if not userid:
|
||||
return {'status': False, 'msg': '未找到用户'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
sessions = await sor.R('chat_session', {'id': session_id, 'userid': userid})
|
||||
if not sessions:
|
||||
return {'status': False, 'msg': '会话不存在'}
|
||||
|
||||
sql = """
|
||||
SELECT id, role, content, content_type, created_at
|
||||
FROM chat_message
|
||||
WHERE session_id = '%s'
|
||||
ORDER BY created_at ASC;
|
||||
""" % _escape(session_id)
|
||||
rows = await sor.sqlExe(sql, {})
|
||||
|
||||
messages = []
|
||||
for row in rows:
|
||||
content = row.get('content') or ''
|
||||
if row.get('content_type') == 'mixed':
|
||||
import json
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(content, list):
|
||||
text_parts = [p.get('text', '') for p in content if p.get('type') == 'text']
|
||||
display = '\n'.join([t for t in text_parts if t]) or '[多媒体消息]'
|
||||
else:
|
||||
display = content
|
||||
messages.append({
|
||||
'id': row['id'],
|
||||
'role': row['role'],
|
||||
'content': display,
|
||||
'created_at': row.get('created_at'),
|
||||
})
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'get messages success',
|
||||
'data': {
|
||||
'session': sessions[0],
|
||||
'messages': messages,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': False, 'msg': 'get messages failed, %s' % str(e)}
|
||||
|
||||
|
||||
ret = await chat_session_messages(params_kw)
|
||||
return ret
|
||||
23
b/cntoai/chat_tables.sql
Normal file
23
b/cntoai/chat_tables.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- 多轮对话:请先执行本脚本创建表后再使用 chat_send / chat_session_* 接口
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `chat_session` (
|
||||
`id` varchar(64) NOT NULL COMMENT '会话ID',
|
||||
`userid` varchar(64) NOT NULL COMMENT '用户ID',
|
||||
`model` varchar(128) NOT NULL COMMENT '模型名称',
|
||||
`title` varchar(255) DEFAULT NULL COMMENT '会话标题(首条问题摘要)',
|
||||
`created_at` datetime DEFAULT current_timestamp() COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_userid_updated` (`userid`, `updated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='模型对话会话';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `chat_message` (
|
||||
`id` varchar(64) NOT NULL COMMENT '消息ID',
|
||||
`session_id` varchar(64) NOT NULL COMMENT '会话ID',
|
||||
`role` varchar(32) NOT NULL COMMENT '角色: user / assistant / system',
|
||||
`content` mediumtext COMMENT '消息内容(纯文本或JSON)',
|
||||
`content_type` varchar(32) DEFAULT 'text' COMMENT 'text / mixed',
|
||||
`created_at` datetime DEFAULT current_timestamp() COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_session_id` (`session_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='模型对话消息';
|
||||
104
b/cntoai/create_model_apikey.dspy
Normal file
104
b/cntoai/create_model_apikey.dspy
Normal file
@ -0,0 +1,104 @@
|
||||
async def create_model_apikey(ns={}):
|
||||
import aiohttp
|
||||
|
||||
if not ns.get('userid'):
|
||||
ns['userid'] = await get_user()
|
||||
|
||||
if not ns.get('userid'):
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到用户'
|
||||
}
|
||||
|
||||
# 通过userid从user_api_keys表中查询opc_apikey
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
records = await sor.R('user_api_keys', {'userid': ns['userid'], 'action': 'sync'})
|
||||
if not records:
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到用户opc_apikey'
|
||||
}
|
||||
|
||||
already_sync_user_key = records[0]['opc_apikey']
|
||||
already_sync_user_appid = records[0]['appid']
|
||||
|
||||
# domain 从数据库params表中获取到pname=cntoai_domain的pvalue值
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
domain = await sor.R('params', {'pname': 'cntoai_domain'})
|
||||
if domain:
|
||||
domain = domain[0]['pvalue']
|
||||
else:
|
||||
debug(f"create_model_apikey未找到域名")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到域名'
|
||||
}
|
||||
|
||||
# 目标URL
|
||||
url = f"{domain}/dapi/apply_apikey.dspy"
|
||||
|
||||
# 请求头
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer %s" % already_sync_user_key
|
||||
}
|
||||
|
||||
# 请求体数据
|
||||
payload = {
|
||||
"appname": ns.get('appname'),
|
||||
"description": ns.get('description'),
|
||||
}
|
||||
|
||||
# 正常返回的是 {'status': 'ok', 'data': {'id': 'HlEQmcbCA1dX0qjhffA_K', 'name': 'cn_ai_user', 'description': '', 'secretkey': 'QUZVcXg5V1p1STMybG5Ia4r9NHBpkeRw558aATmohvZ7GYptvg==', 'allowedips': None, 'orgid': 'KHtWKY2LENTU4hYYim1Ks'}}
|
||||
try:
|
||||
# 创建一个异步会话
|
||||
result_sysnc = None
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 发送POST请求
|
||||
async with session.post(url, headers=headers, data=json.dumps(payload)) as response:
|
||||
# 打印响应状态码
|
||||
debug(f"create_model_apikey状态码: {response.status}")
|
||||
debug(f"create_model_apikey响应: {await response.text()}")
|
||||
result_sysnc = await response.json()
|
||||
|
||||
if not result_sysnc.get('status') == 'ok':
|
||||
debug(f"create_model_apikey创建模型apikey失败: {result_sysnc}")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': f"创建模型apikey失败: {result_sysnc}"
|
||||
}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
# user_api_keys表格 userid/opc_apikey
|
||||
# 首先判断apikey是否存在
|
||||
remote_table_id = result_sysnc['data'].get('id')
|
||||
name = result_sysnc['data'].get('name')
|
||||
secretkey = result_sysnc['data'].get('secretkey')
|
||||
apikey = result_sysnc['data'].get('apikey')
|
||||
|
||||
await sor.C('user_api_keys', {
|
||||
'userid': ns['userid'],
|
||||
'remote_table_id': remote_table_id,
|
||||
'name': name,
|
||||
'opc_apikey': apikey,
|
||||
'secretkey': secretkey,
|
||||
'action': 'user_self_create',
|
||||
})
|
||||
return {
|
||||
'status': True,
|
||||
'msg': '创建模型apikey成功'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
debug(f"sync_cn_ai_user{userid}同步用户失败: {e}")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': f"sync_cn_ai_user{userid}同步用户失败: {e}"
|
||||
}
|
||||
|
||||
|
||||
ret = await create_model_apikey(params_kw)
|
||||
return ret
|
||||
38
b/cntoai/get_deerer_header.dspy
Normal file
38
b/cntoai/get_deerer_header.dspy
Normal file
@ -0,0 +1,38 @@
|
||||
async def get_deerer_header(ns={}):
|
||||
from appPublic.aes import aes_decode_b64, aes_encode_b64
|
||||
if not ns.get('userid'):
|
||||
userid = await get_user()
|
||||
else:
|
||||
userid = ns.get('userid')
|
||||
if not userid:
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '请传递用户ID'
|
||||
}
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
records = await sor.R('user_api_keys', {'userid': userid, 'action': 'sync'})
|
||||
if not records:
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到匹配的用户'
|
||||
}
|
||||
apikey = records[0]['opc_apikey']
|
||||
appid = records[0]['appid']
|
||||
sk = records[0]['secretkey']
|
||||
if not apikey or not appid or not sk:
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '没有找到匹配的用户'
|
||||
}
|
||||
tim = time.time()
|
||||
txt = f'{tim}:{apikey}'
|
||||
cyber = aes_encode_b64(sk, txt)
|
||||
return {
|
||||
'status': True,
|
||||
'data': f'Deerer {appid}-:-{cyber}'
|
||||
}
|
||||
|
||||
|
||||
ret = await get_deerer_header(params_kw)
|
||||
return ret
|
||||
53
b/cntoai/get_model_api_doc.dspy
Normal file
53
b/cntoai/get_model_api_doc.dspy
Normal file
@ -0,0 +1,53 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
async def get_model_api_doc(ns={}):
|
||||
"""
|
||||
根据 model_id 查询模型 API 文档。
|
||||
|
||||
参数:
|
||||
model_id (str) 模型ID,必填
|
||||
|
||||
返回 data 字段:
|
||||
id, model_id, curl_code, python_code, created_at, updated_at
|
||||
"""
|
||||
model_id = ns.get('id')
|
||||
if not model_id:
|
||||
return {'status': False, 'msg': 'model id is required'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
# 通过model_id从model_management表中查询model_name
|
||||
model_name_sql = """
|
||||
SELECT model_name FROM model_management WHERE id = '%s' LIMIT 1;
|
||||
""" % _escape(model_id)
|
||||
model_name = await sor.sqlExe(model_name_sql, {})
|
||||
if not model_name:
|
||||
return {'status': False, 'msg': 'model not found'}
|
||||
model_name = model_name[0]['model_name']
|
||||
|
||||
find_sql = """
|
||||
SELECT id, api_url, model_id, curl_code, python_code, created_at, updated_at
|
||||
FROM model_api_doc
|
||||
WHERE model_id = '%s'
|
||||
LIMIT 1;
|
||||
""" % _escape(model_id)
|
||||
result = await sor.sqlExe(find_sql, {})
|
||||
if not result:
|
||||
return {'status': False, 'msg': 'api doc not found'}
|
||||
result[0]['model_name'] = model_name
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'get model api doc success',
|
||||
'data': result[0],
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': False, 'msg': 'get model api doc failed, %s' % str(e)}
|
||||
|
||||
|
||||
ret = await get_model_api_doc(params_kw)
|
||||
return ret
|
||||
109
b/cntoai/get_model_apikey.dspy
Normal file
109
b/cntoai/get_model_apikey.dspy
Normal file
@ -0,0 +1,109 @@
|
||||
async def get_model_apikey(ns={}):
|
||||
import aiohttp
|
||||
|
||||
if not ns.get('userid'):
|
||||
ns['userid'] = await get_user()
|
||||
|
||||
if not ns.get('userid'):
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到用户'
|
||||
}
|
||||
|
||||
action = ns.get('action')
|
||||
if not action:
|
||||
action = 'user_self_create'
|
||||
|
||||
# 通过userid从user_api_keys表中查询opc_apikey
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
records = await sor.R('user_api_keys', {'userid': ns['userid'], 'action': action})
|
||||
if not records:
|
||||
return {
|
||||
'status': False,
|
||||
'msg': 'apikey不存在'
|
||||
}
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'msg': '获取模型apikey成功',
|
||||
'data': records
|
||||
}
|
||||
# already_sync_user_key = records[0]['opc_apikey']
|
||||
# already_sync_user_appid = records[0]['appid']
|
||||
|
||||
# # domain 从数据库params表中获取到pname=cntoai_domain的pvalue值
|
||||
# db = DBPools()
|
||||
# async with db.sqlorContext('kboss') as sor:
|
||||
# domain = await sor.R('params', {'pname': 'cntoai_domain'})
|
||||
# if domain:
|
||||
# domain = domain[0]['pvalue']
|
||||
# else:
|
||||
# debug(f"get_model_apikey未找到域名")
|
||||
# return {
|
||||
# 'status': False,
|
||||
# 'msg': '未找到域名'
|
||||
# }
|
||||
|
||||
# # 目标URL
|
||||
# url = f"{domain}/dapi/downapps.dspy"
|
||||
|
||||
# # 请求头
|
||||
# headers = {
|
||||
# "Content-Type": "application/json",
|
||||
# "Authorization": "Bearer %s" % already_sync_user_key
|
||||
# }
|
||||
|
||||
# try:
|
||||
# # 创建一个异步会话
|
||||
# result_sysnc = None
|
||||
# async with aiohttp.ClientSession() as session:
|
||||
# # 发送GET请求
|
||||
# async with session.get(url, headers=headers) as response:
|
||||
# # 打印响应状态码
|
||||
# debug(f"get_model_apikey状态码: {response.status}")
|
||||
# result_sysnc = await response.json()
|
||||
|
||||
# if not result_sysnc.get('status') == 'ok':
|
||||
# debug(f"get_model_apikey获取模型apikey失败: {result_sysnc}")
|
||||
# return {
|
||||
# 'status': False,
|
||||
# 'msg': f"获取模型apikey失败: {result_sysnc}"
|
||||
# }
|
||||
|
||||
# db = DBPools()
|
||||
# async with db.sqlorContext('kboss') as sor:
|
||||
# # user_api_keys表格 userid/opc_apikey
|
||||
# # 首先判断apikey是否存在
|
||||
# apikeys = result_sysnc['data']['apikeys']
|
||||
# # 遍历apikeys,如果apikey不存在,则创建, 如果存在则做更新 根据userid和remote_table_id判断
|
||||
# for apikey_item in apikeys:
|
||||
# remote_table_id = apikey_item.get('id')
|
||||
# name = '' if not apikey_item.get('name') else apikey_item.get('name')
|
||||
# apikeyid = apikey_item.get('apikeyid')
|
||||
# exist_record = await sor.R('user_api_keys', {'userid': ns['userid'], 'remote_table_id': remote_table_id})
|
||||
# if exist_record:
|
||||
# update_sql = f"UPDATE user_api_keys SET name = '{name}', opc_apikey = '{apikeyid}' WHERE userid = '{ns['userid']}' AND remote_table_id = '{remote_table_id}'"
|
||||
# await sor.sqlExe(update_sql, {})
|
||||
# else:
|
||||
# await sor.C('user_api_keys', {
|
||||
# 'userid': ns['userid'],
|
||||
# 'remote_table_id': remote_table_id,
|
||||
# 'name': name,
|
||||
# 'opc_apikey': apikeyid,
|
||||
# 'action': 'user_self_create',
|
||||
# })
|
||||
|
||||
# result_sysnc['status'] = True
|
||||
# return result_sysnc
|
||||
|
||||
# except Exception as e:
|
||||
# debug(f"get_model_apikey获取模型apikey失败: {e}")
|
||||
# return {
|
||||
# 'status': False,
|
||||
# 'msg': f"get_model_apikey获取模型apikey失败: {e}"
|
||||
# }
|
||||
|
||||
|
||||
ret = await get_model_apikey(params_kw)
|
||||
return ret
|
||||
45
b/cntoai/get_user_balance.dspy
Normal file
45
b/cntoai/get_user_balance.dspy
Normal file
@ -0,0 +1,45 @@
|
||||
async def get_user_balance(ns={}):
|
||||
"""
|
||||
根据 userid 查询对应机构的客户余额。
|
||||
|
||||
:param userid: 用户 ID
|
||||
:return: 账户余额(与 getCustomerBalance 返回值一致)
|
||||
"""
|
||||
debug(ns)
|
||||
# apikey = ns.get('apikey')
|
||||
userid = ns.get('userid')
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
# if not apikey:
|
||||
# return {
|
||||
# 'status': 'error',
|
||||
# 'msg': 'apikey is required'
|
||||
# }
|
||||
# userid_li = await sor.R('user_api_keys', {'opc_apikey': apikey})
|
||||
# if not userid_li:
|
||||
# return {
|
||||
# 'status': 'error',
|
||||
# 'msg': 'apikey无效,请联系管理员'
|
||||
# }
|
||||
# userid = userid_li[0]['userid']
|
||||
if not userid:
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'userid is required'
|
||||
}
|
||||
user = await sor.R('users', {'id': userid})
|
||||
if not user:
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': '用户不存在'
|
||||
}
|
||||
orgid = await sor.R('organization', {'id': user[0]['orgid']})
|
||||
balance = await getCustomerBalance(sor, orgid[0]['id'])
|
||||
return {
|
||||
'status': 'ok',
|
||||
'balance': balance
|
||||
}
|
||||
|
||||
|
||||
ret = await get_user_balance(params_kw)
|
||||
return ret
|
||||
283
b/cntoai/llm_chat_completions.dspy
Normal file
283
b/cntoai/llm_chat_completions.dspy
Normal file
@ -0,0 +1,283 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _parse_bool(value, default=True):
|
||||
if value is None or value == '':
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _parse_messages(ns):
|
||||
"""解析历史消息:支持 list 或 JSON 字符串"""
|
||||
raw = ns.get('messages')
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def build_user_content(ns):
|
||||
"""
|
||||
构建单条 user 消息的 content,支持文本 / 图片 / 文档链接。
|
||||
|
||||
参数(可组合):
|
||||
message / text 文本
|
||||
image_url 图片 URL
|
||||
image_base64 图片 base64(不含 data: 前缀)
|
||||
document_url 文档 URL(以 file 类型传给兼容接口)
|
||||
document_text 文档纯文本(拼入 text)
|
||||
"""
|
||||
text_parts = []
|
||||
if ns.get('message'):
|
||||
text_parts.append(str(ns.get('message')))
|
||||
if ns.get('text'):
|
||||
text_parts.append(str(ns.get('text')))
|
||||
if ns.get('document_text'):
|
||||
text_parts.append(str(ns.get('document_text')))
|
||||
|
||||
parts = []
|
||||
merged_text = '\n'.join([p for p in text_parts if p]).strip()
|
||||
if merged_text:
|
||||
parts.append({'type': 'text', 'text': merged_text})
|
||||
|
||||
if ns.get('image_url'):
|
||||
parts.append({
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': ns.get('image_url')},
|
||||
})
|
||||
if ns.get('image_base64'):
|
||||
mime = ns.get('image_mime') or 'image/jpeg'
|
||||
b64 = ns.get('image_base64')
|
||||
if not str(b64).startswith('data:'):
|
||||
b64 = 'data:%s;base64,%s' % (mime, b64)
|
||||
parts.append({
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': b64},
|
||||
})
|
||||
if ns.get('document_url'):
|
||||
parts.append({
|
||||
'type': 'file',
|
||||
'file': {'file_url': ns.get('document_url')},
|
||||
})
|
||||
|
||||
if not parts:
|
||||
return ''
|
||||
if len(parts) == 1 and parts[0]['type'] == 'text':
|
||||
return parts[0]['text']
|
||||
return parts
|
||||
|
||||
|
||||
async def _resolve_chat_config(ns, sor):
|
||||
"""解析 API 地址与 Bearer Token"""
|
||||
|
||||
api_url = 'https://api.deepseek.com/chat/completions'
|
||||
api_key = 'sk-c22d6573e85a4d3fa8ab932386cf2909'
|
||||
|
||||
# api_url = ns.get('api_url')
|
||||
# api_key = ns.get('api_key')
|
||||
|
||||
if not api_url and ns.get('model_id'):
|
||||
doc_rows = await sor.sqlExe(
|
||||
"SELECT api_url FROM model_api_doc WHERE model_id = '%s' LIMIT 1;"
|
||||
% _escape(ns.get('model_id')),
|
||||
{},
|
||||
)
|
||||
if doc_rows and doc_rows[0].get('api_url'):
|
||||
api_url = doc_rows[0]['api_url']
|
||||
if not str(api_url).endswith('/chat/completions'):
|
||||
api_url = str(api_url).rstrip('/') + '/chat/completions'
|
||||
|
||||
if not api_url:
|
||||
param_rows = await sor.R('params', {'pname': 'cntoai_llm_chat_url'})
|
||||
if param_rows:
|
||||
api_url = param_rows[0]['pvalue']
|
||||
else:
|
||||
domain_rows = await sor.R('params', {'pname': 'cntoai_domain'})
|
||||
if domain_rows:
|
||||
api_url = domain_rows[0]['pvalue'].rstrip('/') + '/llmage/v1/chat/completions'
|
||||
else:
|
||||
api_url = 'https://ai.atvoe.com/llmage/v1/chat/completions'
|
||||
|
||||
if not api_key:
|
||||
userid = ns.get('userid') or await get_user()
|
||||
if userid:
|
||||
action = ns.get('apikey_action') or 'user_self_create'
|
||||
keys = await sor.R('user_api_keys', {'userid': userid, 'action': action})
|
||||
if not keys:
|
||||
keys = await sor.R('user_api_keys', {'userid': userid, 'action': 'sync'})
|
||||
if keys:
|
||||
api_key = keys[0].get('opc_apikey')
|
||||
if not api_key:
|
||||
key_rows = await sor.R('params', {'pname': 'cntoai_llm_api_key'})
|
||||
if key_rows:
|
||||
api_key = key_rows[0]['pvalue']
|
||||
|
||||
return api_url, api_key
|
||||
|
||||
|
||||
def _extract_stream_piece(payload):
|
||||
"""从 SSE chunk 中提取文本(兼容 OpenAI / Qwen 等格式)"""
|
||||
choice = (payload.get('choices') or [{}])[0]
|
||||
delta = choice.get('delta') or {}
|
||||
message = choice.get('message') or {}
|
||||
piece = (
|
||||
delta.get('content')
|
||||
or delta.get('reasoning_content')
|
||||
or message.get('content')
|
||||
or choice.get('text')
|
||||
or payload.get('content')
|
||||
or ''
|
||||
)
|
||||
if piece is None:
|
||||
return ''
|
||||
return str(piece)
|
||||
|
||||
|
||||
async def _read_stream_response(response):
|
||||
"""解析 SSE 流式响应;若上游未按 SSE 返回则回退解析整段 JSON"""
|
||||
import json
|
||||
chunks = []
|
||||
buffer = ''
|
||||
async for raw in response.content:
|
||||
buffer += raw.decode('utf-8', errors='ignore')
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line or line.startswith(':'):
|
||||
continue
|
||||
if not line.startswith('data:'):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == '[DONE]':
|
||||
return ''.join(chunks), chunks
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
piece = _extract_stream_piece(payload)
|
||||
if piece:
|
||||
chunks.append(piece)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
reply = ''.join(chunks)
|
||||
if reply:
|
||||
return reply, chunks
|
||||
|
||||
# 上游可能忽略 stream=true,直接返回完整 JSON
|
||||
tail = buffer.strip()
|
||||
if tail:
|
||||
try:
|
||||
body = json.loads(tail)
|
||||
choice = (body.get('choices') or [{}])[0]
|
||||
msg = choice.get('message') or {}
|
||||
reply = msg.get('content') or choice.get('text') or ''
|
||||
if reply:
|
||||
return str(reply), [str(reply)]
|
||||
except Exception:
|
||||
pass
|
||||
return reply, chunks
|
||||
|
||||
|
||||
async def llm_chat_completions(ns={}):
|
||||
"""
|
||||
OpenAI 兼容 chat/completions(aiohttp)。
|
||||
|
||||
参数:
|
||||
model (str) 模型名,必填
|
||||
message / text 当前用户文本
|
||||
messages 历史消息 JSON 数组或 list,多轮对话
|
||||
stream (bool) 是否流式,默认 True
|
||||
image_url / image_base64 图片
|
||||
document_url / document_text 文档
|
||||
api_url / api_key 可覆盖默认配置
|
||||
model_id 从 model_api_doc 读取 api_url
|
||||
userid 用于查 user_api_keys
|
||||
"""
|
||||
import aiohttp
|
||||
import json
|
||||
import traceback
|
||||
|
||||
model = ns.get('model')
|
||||
if not model:
|
||||
return {'status': False, 'msg': 'model is required'}
|
||||
|
||||
stream = _parse_bool(ns.get('stream'), True)
|
||||
history = _parse_messages(ns)
|
||||
user_content = build_user_content(ns)
|
||||
if not user_content and not history:
|
||||
return {'status': False, 'msg': 'message is required'}
|
||||
|
||||
messages = list(history)
|
||||
if user_content:
|
||||
messages.append({'role': 'user', 'content': user_content})
|
||||
|
||||
payload = {
|
||||
'model': model,
|
||||
'stream': stream,
|
||||
'messages': messages,
|
||||
}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
api_url, api_key = await _resolve_chat_config(ns, sor)
|
||||
if not api_key:
|
||||
return {'status': False, 'msg': '未找到 API Key,请先创建或配置 cntoai_llm_api_key'}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer %s' % api_key,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=600),
|
||||
) as session:
|
||||
async with session.post(api_url, headers=headers, json=payload) as response:
|
||||
if response.status != 200:
|
||||
err_text = await response.text()
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '模型请求失败 HTTP %s: %s' % (response.status, err_text[:500]),
|
||||
}
|
||||
|
||||
stream_chunks = []
|
||||
if stream:
|
||||
reply, stream_chunks = await _read_stream_response(response)
|
||||
usage = {}
|
||||
else:
|
||||
body = await response.json()
|
||||
choice = (body.get('choices') or [{}])[0]
|
||||
msg = choice.get('message') or {}
|
||||
reply = msg.get('content') or ''
|
||||
usage = body.get('usage') or {}
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'chat success',
|
||||
'data': {
|
||||
'model': model,
|
||||
'reply': reply,
|
||||
'messages': messages + [{'role': 'assistant', 'content': reply}],
|
||||
'usage': usage,
|
||||
'stream': stream,
|
||||
'chunk_count': len(stream_chunks),
|
||||
'chunks': stream_chunks if ns.get('with_chunks') else None,
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
return {'status': False, 'msg': 'chat failed, %s' % traceback.format_exc()}
|
||||
|
||||
|
||||
ret = await llm_chat_completions(params_kw)
|
||||
return ret
|
||||
241
b/cntoai/llm_chat_completions_stream.dspy
Normal file
241
b/cntoai/llm_chat_completions_stream.dspy
Normal file
@ -0,0 +1,241 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _parse_bool(value, default=True):
|
||||
if value is None or value == '':
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _parse_messages(ns):
|
||||
raw = ns.get('messages')
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def build_user_content(ns):
|
||||
text_parts = []
|
||||
if ns.get('message'):
|
||||
text_parts.append(str(ns.get('message')))
|
||||
if ns.get('text'):
|
||||
text_parts.append(str(ns.get('text')))
|
||||
if ns.get('document_text'):
|
||||
text_parts.append(str(ns.get('document_text')))
|
||||
|
||||
parts = []
|
||||
merged_text = '\n'.join([p for p in text_parts if p]).strip()
|
||||
if merged_text:
|
||||
parts.append({'type': 'text', 'text': merged_text})
|
||||
if ns.get('image_url'):
|
||||
parts.append({'type': 'image_url', 'image_url': {'url': ns.get('image_url')}})
|
||||
if ns.get('image_base64'):
|
||||
mime = ns.get('image_mime') or 'image/jpeg'
|
||||
b64 = ns.get('image_base64')
|
||||
if not str(b64).startswith('data:'):
|
||||
b64 = 'data:%s;base64,%s' % (mime, b64)
|
||||
parts.append({'type': 'image_url', 'image_url': {'url': b64}})
|
||||
if ns.get('document_url'):
|
||||
parts.append({'type': 'file', 'file': {'file_url': ns.get('document_url')}})
|
||||
if not parts:
|
||||
return ''
|
||||
if len(parts) == 1 and parts[0]['type'] == 'text':
|
||||
return parts[0]['text']
|
||||
return parts
|
||||
|
||||
|
||||
async def _resolve_chat_config(ns, sor):
|
||||
api_url = ns.get('api_url')
|
||||
api_key = ns.get('api_key')
|
||||
if not api_url and ns.get('model_id'):
|
||||
doc_rows = await sor.sqlExe(
|
||||
"SELECT api_url FROM model_api_doc WHERE model_id = '%s' LIMIT 1;"
|
||||
% _escape(ns.get('model_id')),
|
||||
{},
|
||||
)
|
||||
if doc_rows and doc_rows[0].get('api_url'):
|
||||
api_url = doc_rows[0]['api_url']
|
||||
if not str(api_url).endswith('/chat/completions'):
|
||||
api_url = str(api_url).rstrip('/') + '/chat/completions'
|
||||
if not api_url:
|
||||
param_rows = await sor.R('params', {'pname': 'cntoai_llm_chat_url'})
|
||||
if param_rows:
|
||||
api_url = param_rows[0]['pvalue']
|
||||
else:
|
||||
domain_rows = await sor.R('params', {'pname': 'cntoai_domain'})
|
||||
if domain_rows:
|
||||
api_url = domain_rows[0]['pvalue'].rstrip('/') + '/llmage/v1/chat/completions'
|
||||
else:
|
||||
api_url = 'https://ai.atvoe.com/llmage/v1/chat/completions'
|
||||
if not api_key:
|
||||
userid = ns.get('userid') or await get_user()
|
||||
if userid:
|
||||
action = ns.get('apikey_action') or 'user_self_create'
|
||||
keys = await sor.R('user_api_keys', {'userid': userid, 'action': action})
|
||||
if not keys:
|
||||
keys = await sor.R('user_api_keys', {'userid': userid, 'action': 'sync'})
|
||||
if keys:
|
||||
api_key = keys[0].get('opc_apikey')
|
||||
if not api_key:
|
||||
key_rows = await sor.R('params', {'pname': 'cntoai_llm_api_key'})
|
||||
if key_rows:
|
||||
api_key = key_rows[0]['pvalue']
|
||||
return api_url, api_key
|
||||
|
||||
|
||||
def _extract_stream_piece(payload):
|
||||
choice = (payload.get('choices') or [{}])[0]
|
||||
delta = choice.get('delta') or {}
|
||||
message = choice.get('message') or {}
|
||||
piece = (
|
||||
delta.get('content')
|
||||
or delta.get('reasoning_content')
|
||||
or message.get('content')
|
||||
or choice.get('text')
|
||||
or payload.get('content')
|
||||
or ''
|
||||
)
|
||||
if piece is None:
|
||||
return ''
|
||||
return str(piece)
|
||||
|
||||
|
||||
def _sse_event(obj):
|
||||
import json
|
||||
return 'data: %s\n\n' % json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
|
||||
async def _iter_upstream_stream(api_url, api_key, payload):
|
||||
"""向上游发起流式请求,逐片 yield 文本"""
|
||||
import aiohttp
|
||||
import json
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer %s' % api_key,
|
||||
}
|
||||
payload = dict(payload)
|
||||
payload['stream'] = True
|
||||
|
||||
buffer = ''
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=600)) as session:
|
||||
async with session.post(api_url, headers=headers, json=payload) as response:
|
||||
if response.status != 200:
|
||||
err_text = await response.text()
|
||||
yield {'type': 'error', 'msg': 'HTTP %s: %s' % (response.status, err_text[:500])}
|
||||
return
|
||||
|
||||
async for raw in response.content:
|
||||
buffer += raw.decode('utf-8', errors='ignore')
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line or line.startswith(':') or not line.startswith('data:'):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == '[DONE]':
|
||||
return
|
||||
try:
|
||||
payload_obj = json.loads(data)
|
||||
piece = _extract_stream_piece(payload_obj)
|
||||
if piece:
|
||||
yield {'type': 'content', 'content': piece}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
tail = buffer.strip()
|
||||
if tail:
|
||||
try:
|
||||
body = json.loads(tail)
|
||||
choice = (body.get('choices') or [{}])[0]
|
||||
msg = choice.get('message') or {}
|
||||
piece = msg.get('content') or choice.get('text') or ''
|
||||
if piece:
|
||||
yield {'type': 'content', 'content': str(piece)}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def inference_generator(request, params_kw=None, **kw):
|
||||
"""
|
||||
SSE 流式输出,事件格式:
|
||||
{"type":"meta","model":"..."}
|
||||
{"type":"content","content":"片段"}
|
||||
{"type":"done","reply":"完整文本"}
|
||||
{"type":"error","msg":"..."}
|
||||
结束:data: [DONE]
|
||||
"""
|
||||
import traceback
|
||||
|
||||
ns = params_kw or {}
|
||||
model = ns.get('model')
|
||||
if not model:
|
||||
yield _sse_event({'type': 'error', 'msg': 'model is required'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
history = _parse_messages(ns)
|
||||
user_content = build_user_content(ns)
|
||||
if not user_content and not history:
|
||||
yield _sse_event({'type': 'error', 'msg': 'message is required'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
messages = list(history)
|
||||
if user_content:
|
||||
messages.append({'role': 'user', 'content': user_content})
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
api_url, api_key = await _resolve_chat_config(ns, sor)
|
||||
if not api_key:
|
||||
yield _sse_event({'type': 'error', 'msg': '未找到 API Key'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
yield _sse_event({'type': 'meta', 'model': model, 'stream': True})
|
||||
|
||||
parts = []
|
||||
async for evt in _iter_upstream_stream(api_url, api_key, {
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
}):
|
||||
if evt.get('type') == 'error':
|
||||
yield _sse_event(evt)
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
if evt.get('type') == 'content':
|
||||
parts.append(evt['content'])
|
||||
yield _sse_event(evt)
|
||||
|
||||
reply = ''.join(parts)
|
||||
yield _sse_event({'type': 'done', 'reply': reply, 'model': model})
|
||||
yield 'data: [DONE]\n\n'
|
||||
except Exception:
|
||||
yield _sse_event({'type': 'error', 'msg': traceback.format_exc()})
|
||||
yield 'data: [DONE]\n\n'
|
||||
|
||||
|
||||
async def inference(request, *args, params_kw=None, **kw):
|
||||
from functools import partial
|
||||
env = request._run_ns.copy()
|
||||
f = partial(inference_generator, request, params_kw=params_kw, **kw)
|
||||
return await env.stream_response(request, f, content_type='text/event-stream')
|
||||
|
||||
|
||||
ret = await inference(request, params_kw=params_kw)
|
||||
return ret
|
||||
49
b/cntoai/model_management_add.dspy
Normal file
49
b/cntoai/model_management_add.dspy
Normal file
@ -0,0 +1,49 @@
|
||||
# 可写入/更新的字段(不含 id、created_at、updated_at)
|
||||
_MODEL_FIELDS = (
|
||||
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
|
||||
'context_length', 'input_token_price', 'output_token_price',
|
||||
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
||||
'capabilities', 'limitations', 'highlights', 'is_active',
|
||||
'description', 'listing_status',
|
||||
)
|
||||
|
||||
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _build_model_dict(ns, include_listing_status=False):
|
||||
data = {}
|
||||
for field in _MODEL_FIELDS:
|
||||
if field in ns and ns.get(field) is not None and ns.get(field) != '':
|
||||
data[field] = ns.get(field)
|
||||
if include_listing_status and 'listing_status' not in data:
|
||||
data['listing_status'] = ns.get('listing_status', 0)
|
||||
return data
|
||||
|
||||
|
||||
async def model_management_add(ns={}):
|
||||
"""新增模型,默认待上架 listing_status=0"""
|
||||
if not ns.get('provider') or not ns.get('model_name'):
|
||||
return {'status': False, 'msg': 'provider and model_name are required'}
|
||||
|
||||
ns_dic = _build_model_dict(ns, include_listing_status=True)
|
||||
if 'listing_status' not in ns_dic:
|
||||
ns_dic['listing_status'] = 0
|
||||
if 'is_active' not in ns_dic:
|
||||
ns_dic['is_active'] = 1
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
await sor.C('model_management', ns_dic)
|
||||
return {'status': True, 'msg': 'create model success', 'data': ns_dic}
|
||||
except Exception as e:
|
||||
await sor.rollback()
|
||||
return {'status': False, 'msg': 'create model failed, %s' % str(e)}
|
||||
|
||||
|
||||
ret = await model_management_add(params_kw)
|
||||
return ret
|
||||
94
b/cntoai/model_management_customer_search.dspy
Normal file
94
b/cntoai/model_management_customer_search.dspy
Normal file
@ -0,0 +1,94 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
# 客户侧可见字段(不含 listing_status、is_active 等运营字段)
|
||||
_CUSTOMER_MODEL_COLUMNS = """
|
||||
id, llmid, provider, model_name, display_name, model_type,
|
||||
context_length, input_token_price, output_token_price,
|
||||
cache_hit_input_price, billing_method, billing_unit,
|
||||
capabilities, limitations, highlights, description, sort_order
|
||||
"""
|
||||
|
||||
|
||||
def _customer_listed_conditions(ns):
|
||||
"""已上架且启用的模型;支持按厂商、模型类别筛选"""
|
||||
conditions = ["listing_status = 1", "is_active = 1"]
|
||||
if ns.get('provider'):
|
||||
conditions.append("provider = '%s'" % _escape(ns.get('provider')))
|
||||
if ns.get('model_type'):
|
||||
conditions.append("model_type = '%s'" % _escape(ns.get('model_type')))
|
||||
return ' AND '.join(conditions)
|
||||
|
||||
async def model_management_customer_search(ns={}):
|
||||
"""
|
||||
客户查看模型列表:仅已上架且启用的模型。
|
||||
|
||||
可选参数:
|
||||
provider (str) 厂商,精确匹配筛选
|
||||
model_type (str) 模型类别,精确匹配筛选
|
||||
current_page (int) 页码,默认 1
|
||||
page_size (int) 每页条数,默认 10
|
||||
|
||||
返回 data:
|
||||
provider_list 当前可见模型中的厂商列表(去重)
|
||||
model_type_list 当前可见模型中的模型类别列表(去重)
|
||||
filter_total 当前筛选条件下的模型数量
|
||||
model_list 模型列表
|
||||
page_size, current_page
|
||||
|
||||
调用示例见 model_management_customer_search.dspy
|
||||
"""
|
||||
page_size = int(ns.get('page_size', 1000))
|
||||
current_page = int(ns.get('current_page', 1))
|
||||
offset = (current_page - 1) * page_size
|
||||
where_clause = _customer_listed_conditions(ns)
|
||||
listed_base = "listing_status = 1 AND is_active = 1"
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
provider_sql = """
|
||||
SELECT DISTINCT provider FROM model_management
|
||||
WHERE %s AND provider IS NOT NULL AND provider != ''
|
||||
ORDER BY provider;
|
||||
""" % listed_base
|
||||
model_type_sql = """
|
||||
SELECT DISTINCT model_type FROM model_management
|
||||
WHERE %s AND model_type IS NOT NULL AND model_type != ''
|
||||
ORDER BY model_type;
|
||||
""" % listed_base
|
||||
|
||||
count_sql = """
|
||||
SELECT COUNT(*) AS total_count FROM model_management WHERE %s;
|
||||
""" % where_clause
|
||||
find_sql = """
|
||||
SELECT %s FROM model_management
|
||||
WHERE %s
|
||||
ORDER BY sort_order ASC
|
||||
LIMIT %s OFFSET %s;
|
||||
""" % (_CUSTOMER_MODEL_COLUMNS, where_clause, page_size, offset)
|
||||
|
||||
provider_rows = await sor.sqlExe(provider_sql, {})
|
||||
model_type_rows = await sor.sqlExe(model_type_sql, {})
|
||||
filter_total = (await sor.sqlExe(count_sql, {}))[0]['total_count']
|
||||
model_list = await sor.sqlExe(find_sql, {})
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'customer model search success',
|
||||
'data': {
|
||||
'provider_list': [r['provider'] for r in provider_rows],
|
||||
'model_type_list': [r['model_type'] for r in model_type_rows],
|
||||
'filter_total': filter_total,
|
||||
'page_size': page_size,
|
||||
'current_page': current_page,
|
||||
'model_list': model_list,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': False, 'msg': 'customer model search failed, %s' % str(e)}
|
||||
|
||||
ret = await model_management_customer_search(params_kw)
|
||||
return ret
|
||||
47
b/cntoai/model_management_detail.dspy
Normal file
47
b/cntoai/model_management_detail.dspy
Normal file
@ -0,0 +1,47 @@
|
||||
# 可写入/更新的字段(不含 id、created_at、updated_at)
|
||||
_MODEL_FIELDS = (
|
||||
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
|
||||
'context_length', 'input_token_price', 'output_token_price',
|
||||
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
||||
'capabilities', 'limitations', 'highlights', 'is_active',
|
||||
'description', 'listing_status',
|
||||
)
|
||||
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _build_model_dict(ns, include_listing_status=False):
|
||||
data = {}
|
||||
for field in _MODEL_FIELDS:
|
||||
if field in ns and ns.get(field) is not None and ns.get(field) != '':
|
||||
data[field] = ns.get(field)
|
||||
if include_listing_status and 'listing_status' not in data:
|
||||
data['listing_status'] = ns.get('listing_status', 0)
|
||||
return data
|
||||
|
||||
async def model_management_detail(ns={}):
|
||||
"""根据 id 获取单条模型(编辑页回显)"""
|
||||
model_id = ns.get('id')
|
||||
if not model_id:
|
||||
return {'status': False, 'msg': 'id is required'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
find_sql = "SELECT * FROM model_management WHERE id = '%s' LIMIT 1;" % _escape(model_id)
|
||||
result = await sor.sqlExe(find_sql, {})
|
||||
if not result:
|
||||
return {'status': False, 'msg': 'model not found'}
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'get model detail success',
|
||||
'data': result[0],
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': False, 'msg': 'get model detail failed, %s' % str(e)}
|
||||
|
||||
ret = await model_management_detail(params_kw)
|
||||
return ret
|
||||
25
b/cntoai/model_management_list.dspy
Normal file
25
b/cntoai/model_management_list.dspy
Normal file
@ -0,0 +1,25 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
async def model_management_list(ns={}):
|
||||
"""上架:listing_status 置为 1"""
|
||||
model_id = ns.get('id')
|
||||
if not model_id:
|
||||
return {'status': False, 'msg': 'id is required'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
update_sql = """
|
||||
UPDATE model_management SET listing_status = 1 WHERE id = '%s';
|
||||
""" % _escape(model_id)
|
||||
await sor.sqlExe(update_sql, {})
|
||||
return {'status': True, 'msg': 'model listed success'}
|
||||
except Exception as e:
|
||||
await sor.rollback()
|
||||
return {'status': False, 'msg': 'model list failed, %s' % str(e)}
|
||||
|
||||
ret = await model_management_list(params_kw)
|
||||
return ret
|
||||
67
b/cntoai/model_management_move_down.dspy
Normal file
67
b/cntoai/model_management_move_down.dspy
Normal file
@ -0,0 +1,67 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
async def model_management_move_down(ns={}):
|
||||
"""
|
||||
下移:与排序上的下一条记录交换 sort_order(已在最后则提示)
|
||||
|
||||
必填参数:
|
||||
id (int|str) 模型主键
|
||||
"""
|
||||
model_id = ns.get('id')
|
||||
if not model_id:
|
||||
return {'status': False, 'msg': 'id is required'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
current_sql = """
|
||||
SELECT id, sort_order FROM model_management WHERE id = '%s' LIMIT 1;
|
||||
""" % _escape(model_id)
|
||||
current = await sor.sqlExe(current_sql, {})
|
||||
if not current:
|
||||
return {'status': False, 'msg': 'model not found'}
|
||||
|
||||
cur = current[0]
|
||||
cur_order = int(cur.get('sort_order') or 0)
|
||||
cur_id = int(cur.get('id'))
|
||||
|
||||
next_sql = """
|
||||
SELECT id, sort_order FROM model_management
|
||||
WHERE (sort_order > %s) OR (sort_order = %s AND id > %s)
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
LIMIT 1;
|
||||
""" % (cur_order, cur_order, cur_id)
|
||||
next_row = await sor.sqlExe(next_sql, {})
|
||||
if not next_row:
|
||||
return {'status': True, 'msg': 'already at bottom', 'data': {'sort_order': cur_order}}
|
||||
|
||||
nxt = next_row[0]
|
||||
nxt_order = int(nxt.get('sort_order') or 0)
|
||||
nxt_id = _escape(nxt.get('id'))
|
||||
|
||||
swap_cur_sql = """
|
||||
UPDATE model_management SET sort_order = %s WHERE id = '%s';
|
||||
""" % (nxt_order, _escape(model_id))
|
||||
swap_nxt_sql = """
|
||||
UPDATE model_management SET sort_order = %s WHERE id = '%s';
|
||||
""" % (cur_order, nxt_id)
|
||||
await sor.sqlExe(swap_cur_sql, {})
|
||||
await sor.sqlExe(swap_nxt_sql, {})
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'move down success',
|
||||
'data': {
|
||||
'id': model_id,
|
||||
'sort_order': nxt_order,
|
||||
'swapped_with_id': nxt.get('id'),
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
await sor.rollback()
|
||||
return {'status': False, 'msg': 'move down failed, %s' % str(e)}
|
||||
|
||||
ret = await model_management_move_down(params_kw)
|
||||
return ret
|
||||
49
b/cntoai/model_management_pin_top.dspy
Normal file
49
b/cntoai/model_management_pin_top.dspy
Normal file
@ -0,0 +1,49 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
async def model_management_pin_top(ns={}):
|
||||
"""
|
||||
置顶:将模型排到全局列表最前(sort_order 设为当前最小值 - 1)
|
||||
|
||||
必填参数:
|
||||
id (int|str) 模型主键
|
||||
"""
|
||||
model_id = ns.get('id')
|
||||
if not model_id:
|
||||
return {'status': False, 'msg': 'id is required'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
current_sql = """
|
||||
SELECT id, sort_order FROM model_management WHERE id = '%s' LIMIT 1;
|
||||
""" % _escape(model_id)
|
||||
current = await sor.sqlExe(current_sql, {})
|
||||
if not current:
|
||||
return {'status': False, 'msg': 'model not found'}
|
||||
|
||||
min_sql = "SELECT MIN(sort_order) AS min_order FROM model_management;"
|
||||
min_order = int((await sor.sqlExe(min_sql, {}))[0].get('min_order') or 0)
|
||||
current_order = int(current[0].get('sort_order') or 0)
|
||||
|
||||
if current_order <= min_order:
|
||||
return {'status': True, 'msg': 'already at top', 'data': {'sort_order': current_order}}
|
||||
|
||||
new_order = min_order - 1
|
||||
update_sql = """
|
||||
UPDATE model_management SET sort_order = %s WHERE id = '%s';
|
||||
""" % (new_order, _escape(model_id))
|
||||
await sor.sqlExe(update_sql, {})
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'pin to top success',
|
||||
'data': {'id': model_id, 'sort_order': new_order},
|
||||
}
|
||||
except Exception as e:
|
||||
await sor.rollback()
|
||||
return {'status': False, 'msg': 'pin to top failed, %s' % str(e)}
|
||||
|
||||
ret = await model_management_pin_top(params_kw)
|
||||
return ret
|
||||
96
b/cntoai/model_management_search.dspy
Normal file
96
b/cntoai/model_management_search.dspy
Normal file
@ -0,0 +1,96 @@
|
||||
# 可写入/更新的字段(不含 id、created_at、updated_at)
|
||||
_MODEL_FIELDS = (
|
||||
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
|
||||
'context_length', 'input_token_price', 'output_token_price',
|
||||
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
||||
'capabilities', 'limitations', 'highlights', 'is_active',
|
||||
'description', 'listing_status', 'sort_order',
|
||||
)
|
||||
|
||||
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _build_model_dict(ns, include_listing_status=False):
|
||||
data = {}
|
||||
for field in _MODEL_FIELDS:
|
||||
if field in ns and ns.get(field) is not None and ns.get(field) != '':
|
||||
data[field] = ns.get(field)
|
||||
if include_listing_status and 'listing_status' not in data:
|
||||
data['listing_status'] = ns.get('listing_status', 0)
|
||||
return data
|
||||
|
||||
async def model_management_search(ns={}):
|
||||
"""
|
||||
分页查询模型列表,支持按 model_name / model_type / provider 筛选。
|
||||
返回模型总数、待上架总数、已上架总数,以及厂商列表、模型类型列表。
|
||||
"""
|
||||
import traceback
|
||||
|
||||
page_size = int(ns.get('page_size', 1000))
|
||||
current_page = int(ns.get('current_page', 1))
|
||||
offset = (current_page - 1) * page_size
|
||||
|
||||
conditions = ['1=1']
|
||||
if ns.get('display_name'):
|
||||
display_name = ns.get('display_name')
|
||||
# 模糊查询
|
||||
conditions.append(f"display_name LIKE '%%%%{display_name}%%%%'")
|
||||
if ns.get('model_type'):
|
||||
conditions.append("model_type = '%s'" % _escape(ns.get('model_type')))
|
||||
if ns.get('provider'):
|
||||
conditions.append("provider = '%s'" % _escape(ns.get('provider')))
|
||||
if ns.get('listing_status') is not None and ns.get('listing_status') != '':
|
||||
conditions.append("listing_status = '%s'" % _escape(ns.get('listing_status')))
|
||||
|
||||
where_clause = ' AND '.join(conditions)
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
stats_sql = """SELECT COUNT(*) AS total_count, SUM(CASE WHEN listing_status = 0 THEN 1 ELSE 0 END) AS pending_count, SUM(CASE WHEN listing_status = 1 THEN 1 ELSE 0 END) AS listed_count FROM model_management;"""
|
||||
stats_li = await sor.sqlExe(stats_sql, {})
|
||||
stats = stats_li[0] if stats_li else {}
|
||||
|
||||
provider_sql = """
|
||||
SELECT DISTINCT provider FROM model_management
|
||||
WHERE provider IS NOT NULL AND provider != ''
|
||||
ORDER BY provider;
|
||||
"""
|
||||
model_type_sql = """
|
||||
SELECT DISTINCT model_type FROM model_management
|
||||
WHERE model_type IS NOT NULL AND model_type != ''
|
||||
ORDER BY model_type;
|
||||
"""
|
||||
|
||||
count_sql = """SELECT COUNT(*) AS total_count FROM model_management WHERE %s;""" % where_clause
|
||||
filter_total = (await sor.sqlExe(count_sql, {}))[0]['total_count']
|
||||
|
||||
find_sql = """SELECT * FROM model_management WHERE %s ORDER BY sort_order ASC LIMIT %s OFFSET %s;""" % (where_clause, page_size, offset)
|
||||
provider_rows = await sor.sqlExe(provider_sql, {})
|
||||
model_type_rows = await sor.sqlExe(model_type_sql, {})
|
||||
model_list = await sor.sqlExe(find_sql, {})
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'search model success',
|
||||
'data': {
|
||||
'total_count': stats.get('total_count', 0),
|
||||
'pending_count': int(stats.get('pending_count') or 0),
|
||||
'listed_count': int(stats.get('listed_count') or 0),
|
||||
'provider_list': [r['provider'] for r in provider_rows],
|
||||
'model_type_list': [r['model_type'] for r in model_type_rows],
|
||||
'filter_total': filter_total,
|
||||
'page_size': page_size,
|
||||
'current_page': current_page,
|
||||
'model_list': model_list,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': False, 'msg': 'search model failed, %s' % traceback.format_exc()}
|
||||
|
||||
ret = await model_management_search(params_kw)
|
||||
return ret
|
||||
25
b/cntoai/model_management_unlist.dspy
Normal file
25
b/cntoai/model_management_unlist.dspy
Normal file
@ -0,0 +1,25 @@
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
async def model_management_unlist(ns={}):
|
||||
"""下架:listing_status 置为 0(统计归入待上架)"""
|
||||
model_id = ns.get('id')
|
||||
if not model_id:
|
||||
return {'status': False, 'msg': 'id is required'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
update_sql = """
|
||||
UPDATE model_management SET listing_status = 0 WHERE id = '%s';
|
||||
""" % _escape(model_id)
|
||||
await sor.sqlExe(update_sql, {})
|
||||
return {'status': True, 'msg': 'model unlisted success'}
|
||||
except Exception as e:
|
||||
await sor.rollback()
|
||||
return {'status': False, 'msg': 'model unlist failed, %s' % str(e)}
|
||||
|
||||
ret = await model_management_unlist(params_kw)
|
||||
return ret
|
||||
45
b/cntoai/model_management_update.dspy
Normal file
45
b/cntoai/model_management_update.dspy
Normal file
@ -0,0 +1,45 @@
|
||||
# 可写入/更新的字段(不含 id、created_at、updated_at)
|
||||
_MODEL_FIELDS = (
|
||||
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
|
||||
'context_length', 'input_token_price', 'output_token_price',
|
||||
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
||||
'capabilities', 'limitations', 'highlights', 'is_active',
|
||||
'description', 'listing_status',
|
||||
)
|
||||
|
||||
|
||||
def _escape(value):
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _build_model_dict(ns, include_listing_status=False):
|
||||
data = {}
|
||||
for field in _MODEL_FIELDS:
|
||||
if field in ns and ns.get(field) is not None and ns.get(field) != '':
|
||||
data[field] = ns.get(field)
|
||||
if include_listing_status and 'listing_status' not in data:
|
||||
data['listing_status'] = ns.get('listing_status', 0)
|
||||
return data
|
||||
|
||||
async def model_management_update(ns={}):
|
||||
"""编辑模型,id 必传"""
|
||||
model_id = ns.get('id')
|
||||
if not model_id:
|
||||
return {'status': False, 'msg': 'id is required'}
|
||||
|
||||
ns_dic = _build_model_dict(ns)
|
||||
ns_dic['id'] = model_id
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
await sor.U('model_management', ns_dic)
|
||||
return {'status': True, 'msg': 'update model success'}
|
||||
except Exception as e:
|
||||
await sor.rollback()
|
||||
return {'status': False, 'msg': 'update model failed, %s' % str(e)}
|
||||
|
||||
ret = await model_management_update(params_kw)
|
||||
return ret
|
||||
415
b/cntoai/process_user_billing.dspy
Normal file
415
b/cntoai/process_user_billing.dspy
Normal file
@ -0,0 +1,415 @@
|
||||
async def _lookup_product(sor, providername, productname):
|
||||
"""
|
||||
按厂商名 + 产品名解析 product 记录。
|
||||
依次尝试:provider.name + product.name → providerpid → 仅 product.name
|
||||
"""
|
||||
provider_list = await sor.R('provider', {'name': providername, 'del_flg': '0'})
|
||||
if provider_list:
|
||||
product_list = await sor.R(
|
||||
'product',
|
||||
{'name': productname, 'providerid': provider_list[0]['orgid'], 'del_flg': '0'},
|
||||
)
|
||||
if product_list:
|
||||
return product_list[0]
|
||||
|
||||
product_list = await sor.R('product', {'name': productname, 'providerid': provider_list[0]['orgid'], 'del_flg': '0'})
|
||||
if product_list:
|
||||
return product_list[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _charge_order(sor, orderid, order_type='NEW'):
|
||||
"""
|
||||
确认支付:校验余额 → order2bill → BillAccounting → 更新订单/账单 → customer_goods。
|
||||
逻辑来自 get_baidu_orderlist.dspy 的 affirmbz_order。
|
||||
"""
|
||||
order_rows = await sor.R('bz_order', {'id': orderid})
|
||||
if not order_rows:
|
||||
debug(f"订单不存在")
|
||||
return {'status': 'error', 'msg': '订单不存在'}
|
||||
|
||||
order_row = order_rows[0]
|
||||
product_url = None
|
||||
|
||||
await get_business_date(sor=None)
|
||||
|
||||
count = await getCustomerBalance(sor, order_row['customerid'])
|
||||
if count is None:
|
||||
count = 0
|
||||
if count - float(order_row['amount']) < 0:
|
||||
pricedifference = count - round(order_row['amount'], 2)
|
||||
debug(f"账户余额不足,订单金额: {order_row['amount']}, 账户余额: {count}, 差额: {pricedifference}")
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': '账户余额不足',
|
||||
'pricedifference': round(pricedifference, 10),
|
||||
}
|
||||
|
||||
await order2bill(orderid, sor)
|
||||
bills = await sor.R('bill', {'orderid': orderid, 'del_flg': '0'})
|
||||
try:
|
||||
for bill in bills:
|
||||
ba = BillAccounting(bill)
|
||||
await ba.accounting(sor)
|
||||
dates = datetime.datetime.now()
|
||||
await sor.U('bz_order', {'id': orderid, 'order_status': '1', 'create_at': dates})
|
||||
await sor.U('bill', {'id': orderid, 'bill_state': '1'})
|
||||
|
||||
# 暂时不处理customer_goods
|
||||
# order_goods = await sor.R('order_goods', {'orderid': orderid})
|
||||
# for item in order_goods:
|
||||
# if order_type == 'REFUND':
|
||||
# resource_find_sql = (
|
||||
# "select id from customer_goods where resourceid = '%s';"
|
||||
# % item['resourceids']
|
||||
# )
|
||||
# resource_find_li = await sor.sqlExe(resource_find_sql, {})
|
||||
# resource_find_id = resource_find_li[0]['id']
|
||||
# await sor.U('customer_goods', {'id': resource_find_id, 'del_flg': '1'})
|
||||
# elif order_type == 'RENEW':
|
||||
# resource_find_sql = (
|
||||
# "select id from customer_goods where FIND_IN_SET('%s', resourceid) and del_flg = '0';"
|
||||
# % item['resourceids']
|
||||
# )
|
||||
# resource_find_li = await sor.sqlExe(resource_find_sql, {})
|
||||
# resource_find_id = resource_find_li[0]['id']
|
||||
# await sor.U(
|
||||
# 'customer_goods',
|
||||
# {
|
||||
# 'id': resource_find_id,
|
||||
# 'start_date': item['resourcestarttime'],
|
||||
# 'expire_date': item['resourceendtime'],
|
||||
# },
|
||||
# )
|
||||
# else:
|
||||
# if item.get('chargemode') == 'postpay' and item.get('orderkey') == 'snapshot':
|
||||
# continue
|
||||
|
||||
# product = await sor.R('product', {'id': item['productid']})
|
||||
# now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
# nss = {
|
||||
# 'id': uuid(),
|
||||
# 'providerrid': product[0]['providerid'],
|
||||
# 'productname': product[0]['name'],
|
||||
# 'productdesc': product[0]['description'],
|
||||
# 'customerid': order_row['customerid'],
|
||||
# 'productid': product[0]['id'],
|
||||
# 'specdataid': item.get('spec_id'),
|
||||
# 'orderid': order_row['id'],
|
||||
# 'start_date': item.get('resourcestarttime') or now_str,
|
||||
# 'expire_date': item.get('resourceendtime'),
|
||||
# 'resourceid': item.get('resourceids') or '',
|
||||
# 'orderkey': item.get('orderkey'),
|
||||
# }
|
||||
|
||||
# if product_url:
|
||||
# nss['product_url'] = product_url
|
||||
# else:
|
||||
# spec = (
|
||||
# json.loads(product[0]['spec_note'])
|
||||
# if isinstance(product[0]['spec_note'], str)
|
||||
# else product[0]['spec_note']
|
||||
# )
|
||||
# spec_list_url = [
|
||||
# spec_item['value']
|
||||
# for spec_item in (spec or [])
|
||||
# if spec_item.get('configName') == 'listUrl'
|
||||
# ]
|
||||
# nss['product_url'] = (
|
||||
# spec_list_url[0]
|
||||
# if spec_list_url
|
||||
# else 'https://console.vcp.baidu.com/bcc/#/bcc/instance/list'
|
||||
# )
|
||||
|
||||
# await sor.C('customer_goods', nss)
|
||||
debug(f"支付成功")
|
||||
return {'status': True, 'msg': '支付成功'}
|
||||
except Exception as error:
|
||||
debug(f"支付失败: {error}")
|
||||
return {'status': 'error', 'msg': str(error)}
|
||||
|
||||
async def calc_price_by_saleprotocol(sor, org, product_id, supply_price, quantity=1):
|
||||
"""
|
||||
查 saleprotocol、product_salemode,按折扣计算应付金额。
|
||||
|
||||
:param sor: sqlor 上下文(kboss)
|
||||
:param org: organization 记录,须含 id、parentid
|
||||
:param product_id: product 表主键
|
||||
:param supply_price: 供应价/目录价(折扣前单价,与百度脚本 catalogPrice / itemFee.price 同义)
|
||||
:param quantity: 数量,默认 1
|
||||
:return: dict
|
||||
成功: status=True, amount(行总金额), price(折后单价), list_price, discount
|
||||
失败: status='error', msg
|
||||
"""
|
||||
try:
|
||||
supply_price = abs(float(supply_price))
|
||||
quantity = int(quantity)
|
||||
except (TypeError, ValueError):
|
||||
debug(f"calc_price_by_saleprotocol supply_price / quantity 必须为有效数字")
|
||||
return {'status': 'error', 'msg': 'supply_price / quantity 必须为有效数字'}
|
||||
|
||||
if supply_price <= 0:
|
||||
debug(f"calc_price_by_saleprotocol supply_price 必须大于 0")
|
||||
return {'status': 'error', 'msg': 'supply_price 必须大于 0'}
|
||||
if quantity <= 0:
|
||||
debug(f"calc_price_by_saleprotocol quantity 必须大于 0")
|
||||
return {'status': 'error', 'msg': 'quantity 必须大于 0'}
|
||||
|
||||
saleprotocol_to_person = await sor.R(
|
||||
'saleprotocol',
|
||||
{'bid_orgid': org['id'], 'offer_orgid': org['parentid'], 'del_flg': '0'},
|
||||
)
|
||||
saleprotocol_to_all = await sor.R(
|
||||
'saleprotocol',
|
||||
{
|
||||
'bid_orgid': '*',
|
||||
'offer_orgid': org['parentid'],
|
||||
'del_flg': '0',
|
||||
'salemode': '0',
|
||||
},
|
||||
)
|
||||
|
||||
product_salemode = None
|
||||
if saleprotocol_to_person:
|
||||
product_salemode = await sor.R(
|
||||
'product_salemode',
|
||||
{
|
||||
'protocolid': saleprotocol_to_person[0]['id'],
|
||||
'productid': product_id,
|
||||
'del_flg': '0',
|
||||
},
|
||||
)
|
||||
if not product_salemode and saleprotocol_to_all:
|
||||
product_salemode = await sor.R(
|
||||
'product_salemode',
|
||||
{
|
||||
'protocolid': saleprotocol_to_all[0]['id'],
|
||||
'productid': product_id,
|
||||
'del_flg': '0',
|
||||
},
|
||||
)
|
||||
elif saleprotocol_to_all:
|
||||
product_salemode = await sor.R(
|
||||
'product_salemode',
|
||||
{
|
||||
'protocolid': saleprotocol_to_all[0]['id'],
|
||||
'productid': product_id,
|
||||
'del_flg': '0',
|
||||
},
|
||||
)
|
||||
|
||||
if not product_salemode:
|
||||
debug(f"calc_price_by_saleprotocol 还未上线这个产品的协议配置")
|
||||
return {'status': 'error', 'msg': '还未上线这个产品的协议配置'}
|
||||
|
||||
discount = product_salemode[0]['discount']
|
||||
list_price = supply_price
|
||||
price = abs(round(list_price * discount, 12))
|
||||
amount = abs(round(price * quantity, 12))
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'amount': amount,
|
||||
'price': price,
|
||||
'list_price': list_price,
|
||||
'discount': discount,
|
||||
'protocolid': product_salemode[0]['protocolid'],
|
||||
'product_salemode_id': product_salemode[0].get('id'),
|
||||
}
|
||||
|
||||
async def process_user_billing(ns={}):
|
||||
"""
|
||||
通用记账扣费:创建本地订单 → 校验余额 → 出账记账。
|
||||
|
||||
:param userid: 用户 ID
|
||||
:param providername: 厂商名称(写入 bz_order.source,并用于查 product)
|
||||
:param productname: 产品名称(写入 servicename,并用于查 product)
|
||||
:param amount: 扣费金额;use_saleprotocol='error' 时为最终扣费额;
|
||||
use_saleprotocol=True 时为供应价/目录价(折扣前单价),走协议算价
|
||||
:param use_saleprotocol: 是否启用 saleprotocol_pricing 协议折扣算价,默认 'error' 直接按 amount 扣费
|
||||
:param quantity: 仅 use_saleprotocol=True 时生效,数量默认 1
|
||||
:return: dict,含 status、msg;成功时含 orderid、amount
|
||||
"""
|
||||
# 存储输入值到usage表
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
usage_ns = {
|
||||
'id': uuid(),
|
||||
'userid': ns.get('userid'),
|
||||
'apikey': ns.get('apikey'),
|
||||
'llmid': ns.get('llmid'),
|
||||
'original_price': ns.get('amount'),
|
||||
'usage_content': json.dumps(ns.get('usage')) if isinstance(ns.get('usage'), dict) else ns.get('usage')
|
||||
}
|
||||
await sor.C('model_usage', usage_ns)
|
||||
|
||||
apikey = ns.get('apikey')
|
||||
userid = ns.get('userid')
|
||||
providername = ns.get('providername')
|
||||
productname = ns.get('productname')
|
||||
amount = ns.get('amount')
|
||||
use_saleprotocol = ns.get('use_saleprotocol', True)
|
||||
quantity = int(ns.get('quantity', 1))
|
||||
|
||||
llmid = ns.get('llmid')
|
||||
if not llmid:
|
||||
debug(f"{userid} process_user_billing llmid必传")
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'llmid必传'
|
||||
}
|
||||
|
||||
try:
|
||||
amount = round(float(amount), 12)
|
||||
except (TypeError, ValueError):
|
||||
debug(f"{userid} process_user_billing amount 必须为有效数字")
|
||||
return {'status': 'error', 'msg': 'amount 必须为有效数字'}
|
||||
|
||||
if amount <= 0:
|
||||
debug(f"{userid} process_user_billing amount 必须大于 0")
|
||||
return {'status': 'error', 'msg': 'amount 必须大于 0'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
product_li = await sor.R('product', {'providerpid': llmid, 'del_flg': '0'})
|
||||
if not product_li:
|
||||
debug(f"{userid} process_user_billing 未找到对应产品,请确认")
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': '未找到对应产品,请确认'
|
||||
}
|
||||
product = product_li[0]
|
||||
productname = product['name']
|
||||
providerid = product['providerid']
|
||||
providername_list = await sor.R('organization', {'id': providerid})
|
||||
if not providername_list:
|
||||
debug(f"{userid} process_user_billing 厂商不存在 %s" % providername)
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': '厂商不存在 %s' % providername
|
||||
}
|
||||
providername = providername_list[0]['orgname']
|
||||
|
||||
# userid_li = await sor.R('user_api_keys', {'opc_apikey': apikey})
|
||||
# if not userid_li:
|
||||
# debug(f"{userid} process_user_billing apikey无效,请联系管理员")
|
||||
# return {
|
||||
# 'status': 'error',
|
||||
# 'msg': 'apikey无效,请联系管理员'
|
||||
# }
|
||||
# userid = userid_li[0]['userid']
|
||||
|
||||
user_list = await sor.R('users', {'id': userid})
|
||||
if not user_list:
|
||||
debug(f"{userid} process_user_billing 用户不存在 %s" % userid)
|
||||
return {'status': 'error', 'msg': '用户不存在 %s' % userid}
|
||||
|
||||
org_list = await sor.R('organization', {'id': user_list[0]['orgid']})
|
||||
if not org_list:
|
||||
debug(f"{userid} process_user_billing 用户所属机构不存在")
|
||||
return {'status': 'error', 'msg': '用户所属机构不存在'}
|
||||
|
||||
customerid = org_list[0]['id']
|
||||
# product = await _lookup_product(sor, providername, productname)
|
||||
# if not product:
|
||||
# return {
|
||||
# 'status': 'error',
|
||||
# 'msg': '未找到对应产品,请确认 providername/productname 与库中 provider、product 配置一致',
|
||||
# }
|
||||
|
||||
list_price = amount
|
||||
unit_price = amount
|
||||
discount = 1
|
||||
originalprice = amount
|
||||
|
||||
if use_saleprotocol:
|
||||
price_res = await calc_price_by_saleprotocol(
|
||||
sor, org_list[0], product['id'], amount, quantity=quantity,
|
||||
)
|
||||
if not price_res['status']:
|
||||
return price_res
|
||||
debug(price_res)
|
||||
debug('list_price %s' % list_price)
|
||||
amount = price_res['amount']
|
||||
list_price = price_res['list_price']
|
||||
unit_price = price_res['price']
|
||||
discount = price_res['discount']
|
||||
originalprice = list_price * quantity
|
||||
|
||||
balance = await getCustomerBalance(sor, customerid)
|
||||
if balance is None:
|
||||
balance = 0
|
||||
if amount > balance:
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': '账户余额不足',
|
||||
'pricedifference': round(balance - amount, 12),
|
||||
}
|
||||
|
||||
order_id = uuid()
|
||||
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
bz_ns = {
|
||||
'id': order_id,
|
||||
'order_status': '0',
|
||||
'business_op': 'BUY',
|
||||
'userid': userid,
|
||||
'customerid': customerid,
|
||||
'order_date': now_str,
|
||||
'source': providername,
|
||||
'amount': amount,
|
||||
'originalprice': round(originalprice, 12),
|
||||
'ordertype': 'prepay',
|
||||
'servicename': productname,
|
||||
'is_big_model': 1
|
||||
}
|
||||
await sor.C('bz_order', bz_ns)
|
||||
|
||||
goods_ns = {
|
||||
'id': uuid(),
|
||||
'orderid': order_id,
|
||||
'productid': product['id'],
|
||||
'providerid': product['providerid'],
|
||||
'list_price': list_price,
|
||||
'discount': discount,
|
||||
'quantity': quantity if use_saleprotocol else 1,
|
||||
'price': unit_price,
|
||||
'amount': amount,
|
||||
'chargemode': 'prepay',
|
||||
'servicename': productname,
|
||||
'resourceids': '',
|
||||
'resourcestarttime': now_str,
|
||||
'resourceendtime': None,
|
||||
'is_big_model': 1
|
||||
}
|
||||
await sor.C('order_goods', goods_ns)
|
||||
|
||||
charge_res = await _charge_order(sor, order_id, order_type='NEW')
|
||||
if not charge_res['status']:
|
||||
await sor.rollback()
|
||||
return charge_res
|
||||
|
||||
await sor.U('model_usage', {'id': usage_ns['id'], 'orderid': order_id, 'bill_status': 1})
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'msg': '扣费成功',
|
||||
'orderid': order_id,
|
||||
'amount': amount,
|
||||
'productid': product['id'],
|
||||
}
|
||||
if use_saleprotocol:
|
||||
result['discount'] = discount
|
||||
result['list_price'] = list_price
|
||||
result['price'] = unit_price
|
||||
return result
|
||||
except Exception as e:
|
||||
sor.rollback()
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': str(e)
|
||||
}
|
||||
|
||||
ret = await process_user_billing(params_kw)
|
||||
return ret
|
||||
131
b/cntoai/sync_cn_ai_user.dspy
Normal file
131
b/cntoai/sync_cn_ai_user.dspy
Normal file
@ -0,0 +1,131 @@
|
||||
async def sync_cn_ai_user(ns={}):
|
||||
import aiohttp
|
||||
|
||||
user_info = None
|
||||
if ns.get('userid'):
|
||||
userid = ns.get('userid')
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
user_info = await sor.R('users', {'id': userid})
|
||||
if not user_info:
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到匹配的用户'
|
||||
}
|
||||
userid = user_info[0]['id']
|
||||
orgid = user_info[0]['orgid']
|
||||
username = user_info[0]['username']
|
||||
name = user_info[0]['name']
|
||||
email = user_info[0]['email']
|
||||
|
||||
debug(f"sync_cn_ai_user同步用户: {userid}, {orgid}, {username}, {name}, {email}")
|
||||
# 目标URL
|
||||
# domain 从数据库params表中获取到pname=cntoai_domain的pvalue值
|
||||
domain = None
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
domain = await sor.R('params', {'pname': 'cntoai_domain'})
|
||||
if domain:
|
||||
domain = domain[0]['pvalue']
|
||||
else:
|
||||
debug(f"sync_cn_ai_user未找到域名")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到域名'
|
||||
}
|
||||
already_sync_user_key = await sor.R('params', {'pname': 'cntoai_already_sync_user_key'})
|
||||
if already_sync_user_key:
|
||||
already_sync_user_key = already_sync_user_key[0]['pvalue']
|
||||
else:
|
||||
debug(f"sync_cn_ai_user未找到已同步用户key")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到已同步用户key'
|
||||
}
|
||||
already_sync_user_dappid = await sor.R('params', {'pname': 'cntoai_already_sync_user_dappid'})
|
||||
if already_sync_user_dappid:
|
||||
already_sync_user_dappid = already_sync_user_dappid[0]['pvalue']
|
||||
else:
|
||||
debug(f"sync_cn_ai_user未找到已同步用户dappid")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到已同步用户dappid'
|
||||
}
|
||||
|
||||
url = f"{domain}/rbac/usersync"
|
||||
|
||||
# 请求头
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer %s" % already_sync_user_key
|
||||
}
|
||||
|
||||
# 请求体数据
|
||||
payload = {
|
||||
"action": "single",
|
||||
"dappid": already_sync_user_dappid,
|
||||
"user": {
|
||||
"id": userid,
|
||||
"orgid": orgid,
|
||||
"username": username,
|
||||
"name": name,
|
||||
"email": email
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
# 创建一个异步会话
|
||||
result_sysnc = None
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 发送POST请求
|
||||
async with session.post(url, headers=headers, data=json.dumps(payload)) as response:
|
||||
# 打印响应状态码
|
||||
debug(f"sync_cn_ai_user状态码: {response.status}")
|
||||
result_sysnc = await response.json()
|
||||
|
||||
if not result_sysnc.get('status') == 'success':
|
||||
debug(f"sync_cn_ai_user同步用户失败: {result_sysnc}")
|
||||
return {
|
||||
'status': False
|
||||
}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
# user_api_keys表格 userid/opc_apikey
|
||||
# 首先判断apikey是否存在
|
||||
apikey = result_sysnc['data'][0].get('apikey')
|
||||
appid = result_sysnc['data'][0].get('appid')
|
||||
secretkey = result_sysnc['data'][0].get('secretkey')
|
||||
|
||||
records = await sor.R('user_api_keys', {'opc_apikey': apikey})
|
||||
if records:
|
||||
debug(f"sync_cn_ai_user用户{payload['user']['id']}已存在")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '用户opc_apikey已存在'
|
||||
}
|
||||
await sor.C('user_api_keys', {
|
||||
'userid': userid,
|
||||
'opc_apikey': apikey,
|
||||
'appid': appid,
|
||||
'secretkey': secretkey,
|
||||
'action': 'sync',
|
||||
'expire_time': None,
|
||||
})
|
||||
|
||||
debug(f"sync_cn_ai_user用户{payload['user']['id']}同步成功")
|
||||
return {
|
||||
'status': True,
|
||||
'msg': '用户同步成功'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
debug(f"sync_cn_ai_user{userid}同步用户失败: {e}")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': f"sync_cn_ai_user{userid}同步用户失败: {e}"
|
||||
}
|
||||
|
||||
|
||||
ret = await sync_cn_ai_user(params_kw)
|
||||
return ret
|
||||
262
b/cntoai/test_chat.py
Normal file
262
b/cntoai/test_chat.py
Normal file
@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
cntoai 对话相关接口联调测试(dev.opencomputing.cn)
|
||||
|
||||
用法:
|
||||
pip install requests
|
||||
set CNTOAI_USERID=你的用户id
|
||||
set CNTOAI_API_KEY=你的api_key
|
||||
python test_chat.py
|
||||
|
||||
可选环境变量:
|
||||
CNTOAI_BASE_URL 默认 https://dev.opencomputing.cn
|
||||
CNTOAI_MODEL 默认 qwen3.6-plus
|
||||
CNTOAI_LLM_API_URL 默认 https://ai.atvoe.com/llmage/v1/chat/completions
|
||||
CNTOAI_COOKIE 浏览器 Cookie(未传 userid 时用于鉴权)
|
||||
|
||||
单测:
|
||||
python test_chat.py --only models
|
||||
python test_chat.py --only completions
|
||||
python test_chat.py --only send
|
||||
python test_chat.py --only session
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("请先安装依赖: pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
BASE_URL = "https://dev.opencomputing.cn"
|
||||
USERID = "hSqZuekZ1yKmhKmCN9UAK"
|
||||
API_KEY = "sk-c22d6573e85a4d3fa8ab932386cf2909"
|
||||
# API_URL = "https://ai.atvoe.com/llmage/v1/chat/completions"
|
||||
API_URL = "https://api.deepseek.com/chat/completions"
|
||||
# MODEL = "qwen3.6-plus"
|
||||
MODEL = "deepseek-v4-pro"
|
||||
COOKIE = "".strip()
|
||||
TIMEOUT = int(120)
|
||||
|
||||
|
||||
class ChatApiClient:
|
||||
def __init__(self, base_url: str = BASE_URL):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"Accept": "application/json"})
|
||||
if COOKIE:
|
||||
self.session.headers["Cookie"] = COOKIE
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
path = path if path.startswith("/") else f"/{path}"
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
def _auth(self, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
params: Dict[str, Any] = {}
|
||||
if USERID:
|
||||
params["userid"] = USERID
|
||||
if API_KEY:
|
||||
params["api_key"] = API_KEY
|
||||
if API_URL:
|
||||
params["api_url"] = API_URL
|
||||
if extra:
|
||||
params.update(extra)
|
||||
return params
|
||||
|
||||
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
resp = self.session.get(self._url(path), params=self._auth(params), timeout=TIMEOUT)
|
||||
return self._parse(resp)
|
||||
|
||||
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
resp = self.session.post(
|
||||
self._url(path),
|
||||
json=self._auth(data),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
return self._parse(resp)
|
||||
|
||||
@staticmethod
|
||||
def _parse(resp: requests.Response) -> Dict[str, Any]:
|
||||
print(f" HTTP {resp.status_code} {resp.url[:120]}...")
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"status": False, "msg": f"非 JSON 响应: {resp.text[:300]}"}
|
||||
|
||||
|
||||
def ok(name: str, data: Dict[str, Any]) -> bool:
|
||||
passed = data.get("status") is True
|
||||
tag = "PASS" if passed else "FAIL"
|
||||
print(f"\n[{tag}] {name}")
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2)[:2000])
|
||||
if not passed:
|
||||
print(f" -> {data.get('msg', 'unknown error')}")
|
||||
return passed
|
||||
|
||||
|
||||
def test_model_list(client: ChatApiClient) -> bool:
|
||||
print("\n=== GET /cntoai/model_management_customer_search.dspy ===")
|
||||
data = client.get("/cntoai/model_management_customer_search.dspy", {
|
||||
"page_size": 20,
|
||||
"current_page": 1,
|
||||
})
|
||||
if ok("模型列表", data) and data.get("data"):
|
||||
models = data["data"].get("model_list") or []
|
||||
print(f" 共 {len(models)} 个模型")
|
||||
if models:
|
||||
m0 = models[0]
|
||||
print(f" 首个: {m0.get('model_name')} / {m0.get('display_name')}")
|
||||
return data.get("status") is True
|
||||
|
||||
|
||||
def test_llm_chat_completions(client: ChatApiClient) -> bool:
|
||||
print("\n=== POST /cntoai/llm_chat_completions.dspy ===")
|
||||
data = client.post("/cntoai/llm_chat_completions.dspy", {
|
||||
"model": MODEL,
|
||||
"message": "用一句话介绍你自己",
|
||||
"stream": True,
|
||||
})
|
||||
if ok("直连模型", data) and data.get("data"):
|
||||
print(f" 回复摘要: {(data['data'].get('reply') or '')[:200]}")
|
||||
return data.get("status") is True
|
||||
|
||||
|
||||
def test_chat_send(
|
||||
client: ChatApiClient,
|
||||
session_id: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
print("\n=== POST /cntoai/chat_send.dspy ===")
|
||||
payload: Dict[str, Any] = {
|
||||
"model": MODEL,
|
||||
"message": (
|
||||
"你好,这是 test_chat.py 自动化测试"
|
||||
if not session_id
|
||||
else "继续,用一句话回复我"
|
||||
),
|
||||
"stream": True,
|
||||
}
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
data = client.post("/cntoai/chat_send.dspy", payload)
|
||||
if ok("发送消息", data) and data.get("data"):
|
||||
sid = data["data"].get("session_id")
|
||||
print(f" session_id: {sid}")
|
||||
print(f" 回复摘要: {(data['data'].get('reply') or '')[:200]}")
|
||||
return True, sid
|
||||
return False, session_id
|
||||
|
||||
|
||||
def test_chat_session_list(client: ChatApiClient) -> bool:
|
||||
print("\n=== GET /cntoai/chat_session_list.dspy ===")
|
||||
data = client.get("/cntoai/chat_session_list.dspy", {"page_size": 10})
|
||||
if ok("会话列表", data) and data.get("data"):
|
||||
sessions = data["data"].get("sessions") or []
|
||||
print(f" 共 {data['data'].get('total_count', len(sessions))} 条会话")
|
||||
for s in sessions[:3]:
|
||||
print(f" - {s.get('id')} | {s.get('title')}")
|
||||
return data.get("status") is True
|
||||
|
||||
|
||||
def test_chat_session_messages(client: ChatApiClient, session_id: str) -> bool:
|
||||
print("\n=== GET /cntoai/chat_session_messages.dspy ===")
|
||||
data = client.get("/cntoai/chat_session_messages.dspy", {"session_id": session_id})
|
||||
if ok("会话消息", data) and data.get("data"):
|
||||
msgs = data["data"].get("messages") or []
|
||||
print(f" 消息数: {len(msgs)}")
|
||||
for m in msgs:
|
||||
print(f" [{m.get('role')}] {str(m.get('content') or '')[:80]}")
|
||||
return data.get("status") is True
|
||||
|
||||
|
||||
def test_chat_session_delete(client: ChatApiClient, session_id: str) -> bool:
|
||||
print("\n=== GET /cntoai/chat_session_delete.dspy ===")
|
||||
data = client.get("/cntoai/chat_session_delete.dspy", {"session_id": session_id})
|
||||
return ok("删除会话", data)
|
||||
|
||||
|
||||
def check_config(require_userid: bool = True) -> bool:
|
||||
print("配置:")
|
||||
print(f" BASE_URL = {BASE_URL}")
|
||||
print(f" MODEL = {MODEL}")
|
||||
print(f" API_URL = {API_URL or '(走服务端配置)'}")
|
||||
print(f" USERID = {USERID or '(未设置)'}")
|
||||
print(f" API_KEY = {'已设置' if API_KEY else '(未设置)'}")
|
||||
print(f" COOKIE = {'已设置' if COOKIE else '(未设置)'}")
|
||||
|
||||
if require_userid and not USERID and not COOKIE:
|
||||
print("\n错误: 持久化接口需要 CNTOAI_USERID 或 CNTOAI_COOKIE")
|
||||
return False
|
||||
if not API_KEY:
|
||||
print("\n警告: 未设置 CNTOAI_API_KEY,将依赖服务端 Key")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="cntoai chat API 联调测试")
|
||||
parser.add_argument(
|
||||
"--only",
|
||||
choices=["models", "completions", "send", "session", "delete", "all"],
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--keep-session", action="store_true", help="不删除测试会话")
|
||||
parser.add_argument("--base-url", default=BASE_URL)
|
||||
args = parser.parse_args()
|
||||
|
||||
client = ChatApiClient(base_url=args.base_url)
|
||||
results = []
|
||||
session_id: Optional[str] = None
|
||||
|
||||
if args.only in ("all", "models"):
|
||||
results.append(("models", test_model_list(client)))
|
||||
|
||||
if args.only in ("all", "completions"):
|
||||
if check_config(require_userid=False):
|
||||
results.append(("completions", test_llm_chat_completions(client)))
|
||||
else:
|
||||
results.append(("completions", False))
|
||||
|
||||
if args.only in ("all", "send", "session", "delete"):
|
||||
if not check_config(require_userid=True):
|
||||
return 1
|
||||
|
||||
if args.only in ("all", "send"):
|
||||
passed, session_id = test_chat_send(client)
|
||||
results.append(("send_1", passed))
|
||||
if passed and session_id:
|
||||
time.sleep(1)
|
||||
passed2, session_id = test_chat_send(client, session_id=session_id)
|
||||
results.append(("send_2_multiturn", passed2))
|
||||
|
||||
if args.only in ("all", "session") and session_id:
|
||||
results.append(("session_list", test_chat_session_list(client)))
|
||||
results.append(("session_messages", test_chat_session_messages(client, session_id)))
|
||||
elif args.only == "session":
|
||||
results.append(("session_list", test_chat_session_list(client)))
|
||||
|
||||
if args.only in ("all", "delete") and session_id and not args.keep_session:
|
||||
results.append(("delete", test_chat_session_delete(client, session_id)))
|
||||
elif session_id and args.keep_session:
|
||||
print(f"\n保留测试会话: {session_id}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("汇总:")
|
||||
failed = sum(1 for _, p in results if not p)
|
||||
for name, passed in results:
|
||||
print(f" {'OK' if passed else 'FAIL'} {name}")
|
||||
print("=" * 50)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
247
b/cntoai/test_demo.py
Normal file
247
b/cntoai/test_demo.py
Normal file
@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
chat_send_stream.dspy SSE 流式接口测试
|
||||
|
||||
用法:
|
||||
pip install requests
|
||||
python test_demo.py
|
||||
|
||||
环境变量(可选,覆盖下方默认值):
|
||||
CNTOAI_BASE_URL / CNTOAI_USERID / CNTOAI_API_KEY
|
||||
CNTOAI_MODEL / CNTOAI_LLM_API_URL / CNTOAI_MESSAGE
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Generator, List, Optional
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("请先安装: pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
BASE_URL = os.environ.get("CNTOAI_BASE_URL", "https://dev.opencomputing.cn").rstrip("/")
|
||||
USERID = os.environ.get("CNTOAI_USERID", "hSqZuekZ1yKmhKmCN9UAK").strip()
|
||||
API_KEY = os.environ.get("CNTOAI_API_KEY", "sk-c22d6573e85a4d3fa8ab932386cf2909").strip()
|
||||
API_URL = os.environ.get("CNTOAI_LLM_API_URL", "https://api.deepseek.com/v1/chat/completions").strip()
|
||||
MODEL = os.environ.get("CNTOAI_MODEL", "deepseek-chat").strip()
|
||||
MESSAGE = os.environ.get("CNTOAI_MESSAGE", "你好,请用三句话介绍你自己").strip()
|
||||
TIMEOUT = int(os.environ.get("CNTOAI_TIMEOUT", "300"))
|
||||
|
||||
STREAM_PATH = "/cntoai/chat_send_stream.dspy"
|
||||
|
||||
|
||||
def build_payload(session_id: Optional[str] = None, message: Optional[str] = None) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"model": MODEL,
|
||||
"message": message or MESSAGE,
|
||||
"userid": USERID,
|
||||
"api_key": API_KEY,
|
||||
"api_url": API_URL,
|
||||
}
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
return payload
|
||||
|
||||
|
||||
def parse_sse_text(text: str) -> List[Dict[str, Any]]:
|
||||
events: List[Dict[str, Any]] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
events.append(json.loads(data))
|
||||
except json.JSONDecodeError:
|
||||
print(f"[warn] 无法解析: {line[:200]}")
|
||||
return events
|
||||
|
||||
|
||||
def parse_sse_stream(response: requests.Response) -> Generator[Dict[str, Any], None, None]:
|
||||
"""
|
||||
按字节缓冲解析 SSE。
|
||||
勿用 iter_lines(decode_unicode=True):TCP 分块可能截断 UTF-8 多字节字符,导致乱码和 JSON 解析失败。
|
||||
"""
|
||||
buffer = b""
|
||||
for chunk in response.iter_content(chunk_size=4096):
|
||||
if not chunk:
|
||||
continue
|
||||
buffer += chunk
|
||||
while b"\n" in buffer:
|
||||
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||
if not line_bytes.strip():
|
||||
continue
|
||||
line = line_bytes.decode("utf-8").strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
print(f"\n[warn] JSON 解析失败: {line[:120]}...")
|
||||
|
||||
tail = buffer.strip()
|
||||
if tail:
|
||||
line = tail.decode("utf-8", errors="replace").strip()
|
||||
if line.startswith("data:"):
|
||||
data = line[5:].strip()
|
||||
if data and data != "[DONE]":
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
def diagnose_empty_response(resp: requests.Response) -> None:
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
body = resp.content or b""
|
||||
print("\n[诊断] 响应体为空或无可解析 SSE")
|
||||
print(f" Content-Type : {ctype}")
|
||||
print(f" body 长度 : {len(body)}")
|
||||
if body:
|
||||
print(f" body 前 500B : {body[:500]!r}")
|
||||
if "text/html" in ctype and len(body) == 0:
|
||||
print("\n 可能原因: chat_send_stream.dspy 未执行 inference 入口。")
|
||||
print(" 请确认文件末尾包含:")
|
||||
print(" ret = await inference(request, params_kw=params_kw)")
|
||||
print(" return ret")
|
||||
print(" 并重新部署到 dev 后再测。")
|
||||
|
||||
|
||||
def test_chat_send_stream(session_id: Optional[str] = None, message: Optional[str] = None) -> Optional[str]:
|
||||
url = BASE_URL + STREAM_PATH
|
||||
payload = build_payload(session_id=session_id, message=message)
|
||||
|
||||
print("=" * 60)
|
||||
print("chat_send_stream.dspy 流式测试")
|
||||
print(f" URL : {url}")
|
||||
print(f" MODEL : {MODEL}")
|
||||
print(f" USERID : {USERID}")
|
||||
print(f" API_URL : {API_URL}")
|
||||
print(f" message : {payload.get('message')}")
|
||||
if session_id:
|
||||
print(f" session : {session_id}")
|
||||
print("=" * 60)
|
||||
|
||||
if not USERID:
|
||||
print("错误: 请设置 CNTOAI_USERID")
|
||||
return None
|
||||
|
||||
resp = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Accept": "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
stream=True,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
print(f"\nHTTP {resp.status_code} Content-Type: {ctype}\n")
|
||||
|
||||
if resp.status_code != 200:
|
||||
print(resp.text[:500])
|
||||
return None
|
||||
|
||||
if "text/event-stream" not in ctype:
|
||||
raw = resp.content
|
||||
diagnose_empty_response(resp)
|
||||
if raw:
|
||||
for evt in parse_sse_text(raw.decode("utf-8", errors="ignore")):
|
||||
print("[parsed]", evt)
|
||||
return None
|
||||
|
||||
session_out: Optional[str] = session_id
|
||||
full_reply: List[str] = []
|
||||
has_content = False
|
||||
event_count = 0
|
||||
|
||||
print("--- 流式输出 ---")
|
||||
for evt in parse_sse_stream(resp):
|
||||
event_count += 1
|
||||
etype = evt.get("type")
|
||||
|
||||
if etype == "meta":
|
||||
session_out = evt.get("session_id") or session_out
|
||||
print(f"[meta] session_id={session_out} model={evt.get('model')}")
|
||||
continue
|
||||
|
||||
if etype == "content":
|
||||
piece = evt.get("content") or ""
|
||||
has_content = True
|
||||
full_reply.append(piece)
|
||||
print(piece, end="", flush=True)
|
||||
continue
|
||||
|
||||
if etype == "done":
|
||||
session_out = evt.get("session_id") or session_out
|
||||
reply = evt.get("reply") or ""
|
||||
print(f"\n\n[done] session_id={session_out}")
|
||||
print(f"[done] reply 长度={len(reply)}")
|
||||
if reply and not has_content:
|
||||
print(reply)
|
||||
continue
|
||||
|
||||
if etype == "error":
|
||||
print(f"\n[error] {evt.get('msg')}")
|
||||
return session_out
|
||||
|
||||
print(f"\n[unknown] {evt}")
|
||||
|
||||
print("\n--- 结束 ---")
|
||||
if event_count == 0:
|
||||
diagnose_empty_response(resp)
|
||||
elif full_reply:
|
||||
joined = "".join(full_reply)
|
||||
print(f"拼接回复({len(joined)}字): {joined[:300]}...")
|
||||
return session_out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parser = argparse.ArgumentParser(description="chat_send_stream.dspy SSE 测试")
|
||||
parser.add_argument("--session-id", help="续聊会话 ID")
|
||||
parser.add_argument("--message", "-m", help="覆盖默认 message")
|
||||
parser.add_argument("--twice", action="store_true", help="同一会话连发两条")
|
||||
args = parser.parse_args()
|
||||
|
||||
sid = test_chat_send_stream(session_id=args.session_id, message=args.message)
|
||||
if sid is None:
|
||||
return 1
|
||||
|
||||
if args.twice and sid:
|
||||
print("\n" + "=" * 60)
|
||||
print("第二轮(多轮续聊)")
|
||||
sid2 = test_chat_send_stream(
|
||||
session_id=sid,
|
||||
message=args.message or "继续,用一句话总结上面内容",
|
||||
)
|
||||
if sid2 is None:
|
||||
return 1
|
||||
|
||||
if sid:
|
||||
print(f"\n提示: 续聊 python test_demo.py --session-id {sid}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
70
b/customer/forgotPassword.dspy
Normal file
70
b/customer/forgotPassword.dspy
Normal file
@ -0,0 +1,70 @@
|
||||
async def forgotPassword(ns):
|
||||
"""
|
||||
忘记密码:校验短信验证码后重置密码。
|
||||
|
||||
参数:
|
||||
id (str) 用户ID(找回验证码接口返回的 userid)
|
||||
password (str) 新密码
|
||||
codeid (str) 验证码ID
|
||||
vcode (str) 验证码
|
||||
|
||||
也可传 mobile 或 username 定位用户(未传 id 时)。
|
||||
"""
|
||||
import re
|
||||
import traceback
|
||||
|
||||
if not ns.get('password'):
|
||||
return {'status': False, 'msg': '新密码不能为空'}
|
||||
if len(ns.get('password')) < 8 or not re.search(r'[a-zA-Z]', ns.get('password')) or not re.search(r'[0-9]', ns.get('password')):
|
||||
return {'status': False, 'msg': '密码至少8位,包含大小写字母、特殊字符、数字'}
|
||||
if not ns.get('codeid'):
|
||||
return {'status': False, 'msg': '验证码ID不能为空'}
|
||||
if not ns.get('vcode'):
|
||||
return {'status': False, 'msg': '验证码不能为空'}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
code = await sor.R('validatecode', {'id': ns.get('codeid'), 'vcode': ns.get('vcode')})
|
||||
if code:
|
||||
create_at = code[0]['create_at']
|
||||
now = datetime.datetime.now()
|
||||
create_at_dt = datetime.datetime.strptime(create_at, "%Y-%m-%d %H:%M:%S")
|
||||
if (now - create_at_dt).seconds > 500:
|
||||
return {'status': False, 'msg': '验证码过期'}
|
||||
else:
|
||||
return {'status': False, 'msg': '验证码不正确'}
|
||||
|
||||
user = None
|
||||
if ns.get('id'):
|
||||
users = await sor.R('users', {'id': ns.get('id'), 'del_flg': '0'})
|
||||
if users:
|
||||
user = users[0]
|
||||
elif ns.get('mobile'):
|
||||
users = await sor.R('users', {'mobile': ns.get('mobile'), 'del_flg': '0'})
|
||||
if users:
|
||||
user = users[0]
|
||||
elif ns.get('username'):
|
||||
users = await sor.R('users', {'username': ns.get('username'), 'del_flg': '0'})
|
||||
if not users:
|
||||
users = await sor.R('users', {'mobile': ns.get('username'), 'del_flg': '0'})
|
||||
if users:
|
||||
user = users[0]
|
||||
else:
|
||||
return {'status': False, 'msg': '用户标识不能为空'}
|
||||
|
||||
if not user:
|
||||
return {'status': False, 'msg': '用户不存在'}
|
||||
|
||||
new_password = password_encode(ns['password'])
|
||||
update_sql = """UPDATE users SET password = '%s' WHERE id = '%s';""" % (new_password, user['id'])
|
||||
await sor.sqlExe(update_sql, {})
|
||||
return {'status': True, 'msg': '密码重置成功'}
|
||||
except Exception as error:
|
||||
debug(f"forgotPassword 错误: {error}")
|
||||
debug(f"forgotPassword 错误堆栈: {traceback.format_exc()}")
|
||||
return {'status': False, 'msg': '密码重置失败, %s' % str(error)}
|
||||
|
||||
|
||||
ret = await forgotPassword(params_kw)
|
||||
return ret
|
||||
@ -1,7 +1,118 @@
|
||||
async def sync_cn_ai_user(userid=None, orgid=None, username=None, name=None, email=None):
|
||||
import aiohttp
|
||||
debug(f"sync_cn_ai_user同步用户: {userid}, {orgid}, {username}, {name}, {email}")
|
||||
# 目标URL
|
||||
# domain 从数据库params表中获取到pname=cntoai_domain的pvalue值
|
||||
domain = None
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
domain = await sor.R('params', {'pname': 'cntoai_domain'})
|
||||
if domain:
|
||||
domain = domain[0]['pvalue']
|
||||
else:
|
||||
debug(f"sync_cn_ai_user未找到域名")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到域名'
|
||||
}
|
||||
already_sync_user_key = await sor.R('params', {'pname': 'cntoai_already_sync_user_key'})
|
||||
if already_sync_user_key:
|
||||
already_sync_user_key = already_sync_user_key[0]['pvalue']
|
||||
else:
|
||||
debug(f"sync_cn_ai_user未找到已同步用户key")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到已同步用户key'
|
||||
}
|
||||
already_sync_user_dappid = await sor.R('params', {'pname': 'cntoai_already_sync_user_dappid'})
|
||||
if already_sync_user_dappid:
|
||||
already_sync_user_dappid = already_sync_user_dappid[0]['pvalue']
|
||||
else:
|
||||
debug(f"sync_cn_ai_user未找到已同步用户dappid")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '未找到已同步用户dappid'
|
||||
}
|
||||
|
||||
url = f"{domain}/rbac/usersync"
|
||||
|
||||
# 请求头
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer %s" % already_sync_user_key
|
||||
}
|
||||
|
||||
# 请求体数据
|
||||
payload = {
|
||||
"action": "single",
|
||||
"dappid": already_sync_user_dappid,
|
||||
"user": {
|
||||
"id": userid,
|
||||
"orgid": orgid,
|
||||
"username": username,
|
||||
"name": name,
|
||||
"email": email
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
# 创建一个异步会话
|
||||
result_sysnc = None
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 发送POST请求
|
||||
async with session.post(url, headers=headers, data=json.dumps(payload)) as response:
|
||||
# 打印响应状态码
|
||||
debug(f"sync_cn_ai_user状态码: {response.status}")
|
||||
result_sysnc = await response.json()
|
||||
|
||||
if not result_sysnc.get('status') == 'success':
|
||||
debug(f"sync_cn_ai_user同步用户失败: {result_sysnc}")
|
||||
return {
|
||||
'status': False
|
||||
}
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
# user_api_keys表格 userid/opc_apikey
|
||||
# 首先判断apikey是否存在
|
||||
apikey = result_sysnc['data'][0].get('apikey')
|
||||
appid = result_sysnc['data'][0].get('appid')
|
||||
secretkey = result_sysnc['data'][0].get('secretkey')
|
||||
|
||||
records = await sor.R('user_api_keys', {'opc_apikey': apikey})
|
||||
if records:
|
||||
debug(f"sync_cn_ai_user用户{payload['user']['id']}已存在")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': '用户opc_apikey已存在'
|
||||
}
|
||||
await sor.C('user_api_keys', {
|
||||
'userid': userid,
|
||||
'opc_apikey': apikey,
|
||||
'appid': appid,
|
||||
'secretkey': secretkey,
|
||||
'action': 'sync',
|
||||
'expire_time': None,
|
||||
})
|
||||
|
||||
debug(f"sync_cn_ai_user用户{payload['user']['id']}同步成功")
|
||||
return {
|
||||
'status': True,
|
||||
'msg': '用户同步成功'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
debug(f"sync_cn_ai_user{userid}同步用户失败: {e}")
|
||||
return {
|
||||
'status': False,
|
||||
'msg': f"sync_cn_ai_user{userid}同步用户失败: {e}"
|
||||
}
|
||||
|
||||
async def registerUser(ns):
|
||||
"""
|
||||
用户注册
|
||||
"""
|
||||
import re
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
if ns:
|
||||
@ -27,7 +138,7 @@ async def registerUser(ns):
|
||||
|
||||
if ns.get('password'):
|
||||
# 至少8位,包含大小写字母、特殊字符、数字
|
||||
if len(ns.get('password')) < 8 or not re.search(r'[a-zA-Z]', ns.get('password')) or not re.search(r'[0-9]', ns.get('password')) or not re.search(r'[!@#$%^&*()_+{}|:"<>?]', ns.get('password')):
|
||||
if len(ns.get('password')) < 8 or not re.search(r'[a-zA-Z]', ns.get('password')) or not re.search(r'[0-9]', ns.get('password')):
|
||||
return {'status': False, 'msg': '密码至少8位,包含大小写字母、特殊字符、数字'}
|
||||
|
||||
if not ns.get('codeid'):
|
||||
@ -176,6 +287,10 @@ async def registerUser(ns):
|
||||
ns['customerid'] = org_id
|
||||
await sor.C('customer', ns)
|
||||
await openCustomerAccounts(sor, org[0]['id'], org_id)
|
||||
|
||||
# 同步用户
|
||||
await sync_cn_ai_user(userid=userid, orgid=ns_org['id'], username=ns['username'], name=ns['username'])
|
||||
|
||||
return {'status': True, 'msg': '注册成功'}
|
||||
except Exception as error:
|
||||
# raise error
|
||||
|
||||
@ -539,11 +539,17 @@ jiajie_ali_products = [
|
||||
]
|
||||
|
||||
async def get_firstpage_product_tree(ns={}):
|
||||
token_market = await path_call('../cntoai/model_management_customer_search.dspy', ns)
|
||||
# if token_market.get('status'):
|
||||
# token_market = token_market.get('data')
|
||||
# else:
|
||||
# token_market = None
|
||||
|
||||
data = {
|
||||
"product_service": [
|
||||
{
|
||||
"id": "1",
|
||||
"firTitle": "云",
|
||||
"firTitle": "基础云",
|
||||
"secMenu": [
|
||||
{
|
||||
"id": "10",
|
||||
@ -576,44 +582,79 @@ async def get_firstpage_product_tree(ns={}):
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': "2", 'firTitle': "算", 'secMenu': [
|
||||
{
|
||||
'id': '21', 'secTitle': '智算', 'thrMenu': [
|
||||
'id': "2", 'firTitle': "TOKEN市集", 'secMenu': [
|
||||
{
|
||||
'id': '211',
|
||||
'thrTitle': None,
|
||||
'value': [#{'id': '2111', 'name': '容器云'},
|
||||
{'id': '2113', 'name': '裸金属'},
|
||||
#{'id': '2114', 'name': '裸金属-910B'},
|
||||
{'id': '2115', 'name': '一体机-昆仑芯'},
|
||||
{'id': '2112', 'name': '一体机-天数智芯'},]
|
||||
# 'id': '21', 'secTitle': '智算', 'thrMenu': [
|
||||
# {
|
||||
# 'id': '211',
|
||||
# 'thrTitle': None,
|
||||
# 'value': [#{'id': '2111', 'name': '容器云'},
|
||||
# {'id': '2113', 'name': '裸金属'},
|
||||
# #{'id': '2114', 'name': '裸金属-910B'},
|
||||
# {'id': '2115', 'name': '一体机-昆仑芯'},
|
||||
# {'id': '2112', 'name': '一体机-天数智芯'},]
|
||||
# },
|
||||
# ],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
'token_market': token_market
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"firTitle": "网",
|
||||
"secMenu": [
|
||||
{
|
||||
"id": "31",
|
||||
"secTitle": "算力网络",
|
||||
"thrMenu": [
|
||||
{
|
||||
"id": "311",
|
||||
"thrTitle": None,
|
||||
"value": [{'id': '3111', 'name': '互联网专线'},
|
||||
{'id': '3121', 'name': 'SDWAN'},
|
||||
{'id': '3131', 'name': 'DCI'},
|
||||
{'id': '3141', 'name': 'AI专线'}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
'id': "2", 'firTitle': "元境", 'secMenu': [
|
||||
# {
|
||||
# 'id': '21', 'secTitle': '智算', 'thrMenu': [
|
||||
# {
|
||||
# 'id': '211',
|
||||
# 'thrTitle': None,
|
||||
# 'value': [#{'id': '2111', 'name': '容器云'},
|
||||
# {'id': '2113', 'name': '裸金属'},
|
||||
# #{'id': '2114', 'name': '裸金属-910B'},
|
||||
# {'id': '2115', 'name': '一体机-昆仑芯'},
|
||||
# {'id': '2112', 'name': '一体机-天数智芯'},]
|
||||
# },
|
||||
# ],
|
||||
# },
|
||||
]
|
||||
},
|
||||
# {
|
||||
# 'id': "2", 'firTitle': "算", 'secMenu': [
|
||||
# {
|
||||
# 'id': '21', 'secTitle': '智算', 'thrMenu': [
|
||||
# {
|
||||
# 'id': '211',
|
||||
# 'thrTitle': None,
|
||||
# 'value': [#{'id': '2111', 'name': '容器云'},
|
||||
# {'id': '2113', 'name': '裸金属'},
|
||||
# #{'id': '2114', 'name': '裸金属-910B'},
|
||||
# {'id': '2115', 'name': '一体机-昆仑芯'},
|
||||
# {'id': '2112', 'name': '一体机-天数智芯'},]
|
||||
# },
|
||||
# ],
|
||||
# },
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "id": "3",
|
||||
# "firTitle": "网",
|
||||
# "secMenu": [
|
||||
# {
|
||||
# "id": "31",
|
||||
# "secTitle": "算力网络",
|
||||
# "thrMenu": [
|
||||
# {
|
||||
# "id": "311",
|
||||
# "thrTitle": None,
|
||||
# "value": [{'id': '3111', 'name': '互联网专线'},
|
||||
# {'id': '3121', 'name': 'SDWAN'},
|
||||
# {'id': '3131', 'name': 'DCI'},
|
||||
# {'id': '3141', 'name': 'AI专线'}
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "id": "4",
|
||||
# "firTitle": "模型",
|
||||
# "secMenu": []
|
||||
@ -623,44 +664,44 @@ async def get_firstpage_product_tree(ns={}):
|
||||
# "firTitle": "服务",
|
||||
# "secMenu": []
|
||||
# },
|
||||
{
|
||||
"id": "6",
|
||||
"firTitle": "用",
|
||||
"secMenu": [
|
||||
{
|
||||
"id": "61",
|
||||
"secTitle": "AI应用",
|
||||
"thrMenu": [
|
||||
{
|
||||
"id": "611",
|
||||
"thrTitle": "智慧医疗",
|
||||
"value": [
|
||||
{
|
||||
"id": "6111",
|
||||
"name": "灵医智能体"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "612",
|
||||
"thrTitle": "智慧客服",
|
||||
"value": [
|
||||
{
|
||||
"id": "6112",
|
||||
"name": "客悦·智能客服"
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
# {
|
||||
# "id": "6",
|
||||
# "firTitle": "用",
|
||||
# "secMenu": [
|
||||
# {
|
||||
# "id": "61",
|
||||
# "secTitle": "AI应用",
|
||||
# "thrMenu": [
|
||||
# {
|
||||
# "id": "611",
|
||||
# "thrTitle": "智慧医疗",
|
||||
# "value": [
|
||||
# {
|
||||
# "id": "6111",
|
||||
# "name": "灵医智能体"
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "id": "612",
|
||||
# "thrTitle": "智慧客服",
|
||||
# "value": [
|
||||
# {
|
||||
# "id": "6112",
|
||||
# "name": "客悦·智能客服"
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# ]
|
||||
# },
|
||||
# ]
|
||||
# }
|
||||
]
|
||||
}
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('kboss') as sor:
|
||||
try:
|
||||
if ns.get('url_link') and ('kaiyuancloud' in ns.get('url_link') or 'opencomputing' in ns.get('url_link')):
|
||||
if ns.get('url_link') and ('kaiyuancloud' in ns.get('url_link') or 'opencomputing' in ns.get('url_link') or 'ncmatch' in ns.get('url_link')):
|
||||
data_baidu = {
|
||||
"id": "12",
|
||||
"secTitle": "阿里云",
|
||||
|
||||
@ -112,6 +112,18 @@ async def mobilecode(ns):
|
||||
return {'status': False, 'msg': '发送失败'}
|
||||
else:
|
||||
return {'status': False, 'action': 'redirect', 'msg': '用户未注册, 请到注册页面注册'}
|
||||
|
||||
# 忘记密码逻辑:检查手机号是否存在
|
||||
elif action_type == 'forgotpassword':
|
||||
if len(userreacs) >= 1:
|
||||
code = await generate_vcode()
|
||||
nss = await send_vcode(userreacs[0]['mobile'], '用户注册登录验证', {'SMSvCode': code.get('vcode')})
|
||||
if nss['status']:
|
||||
return {'status': True, 'msg': '验证码发送成功', 'codeid': code.get('id')}
|
||||
else:
|
||||
return {'status': False, 'msg': '发送失败'}
|
||||
else:
|
||||
return {'status': False, 'action': 'redirect', 'msg': '用户未注册, 请到注册页面注册'}
|
||||
|
||||
# 原有逻辑:如果没有指定action_type,保持原有逻辑
|
||||
else:
|
||||
|
||||
9
f/web-kboss/src/api/gotoYuanJing.js
Normal file
9
f/web-kboss/src/api/gotoYuanJing.js
Normal file
@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
// 跳转远景
|
||||
export function gotoYuanJingAPI(data) {
|
||||
return request({
|
||||
url: `cntoai/get_deerer_header.dspy`,
|
||||
method: 'get',
|
||||
params: data
|
||||
})
|
||||
}
|
||||
@ -72,7 +72,7 @@ export function retrieveCodeAPI(data) {
|
||||
//重置密码
|
||||
export function getPasswordCodeAPI(params) {
|
||||
return request({
|
||||
url: `/user/getretrieve${suffix}`,
|
||||
url: `/customer/forgotPassword${suffix}`,
|
||||
method: 'get',
|
||||
params: params
|
||||
})
|
||||
@ -283,3 +283,13 @@ export function register(data) {
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 新忘记密码
|
||||
export function newForgotPassword(data) {
|
||||
return request({
|
||||
url: `/customer/forgotPassword.dspy`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
115
f/web-kboss/src/api/model/model.js
Normal file
115
f/web-kboss/src/api/model/model.js
Normal file
@ -0,0 +1,115 @@
|
||||
import request from "@/utils/request";
|
||||
// 获取模型列表
|
||||
export const reqModelList = (params = {}) => {
|
||||
return request({
|
||||
url: 'cntoai/model_management_search.dspy',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 上架
|
||||
export const reqModelUp = (id) => {
|
||||
return request({
|
||||
url: '/cntoai/model_management_list.dspy',
|
||||
method: 'get',
|
||||
params: { id }
|
||||
})
|
||||
}
|
||||
// 下架
|
||||
export const reqModelDown = (id) => {
|
||||
return request({
|
||||
url: '/cntoai/model_management_unlist.dspy',
|
||||
method: 'get',
|
||||
params: { id }
|
||||
})
|
||||
}
|
||||
// 模型详情
|
||||
export const reqModelDetail = (id) => {
|
||||
return request({
|
||||
url: '/cntoai/model_management_detail.dspy',
|
||||
method: 'get',
|
||||
params: { id }
|
||||
})
|
||||
}
|
||||
// 编辑模型
|
||||
export const reqModelEdit = (data) => {
|
||||
return request({
|
||||
url: '/cntoai/model_management_update.dspy',
|
||||
method: 'get',
|
||||
params: data
|
||||
})
|
||||
}
|
||||
// 置顶
|
||||
export const reqModelTop = (id) => {
|
||||
return request({
|
||||
url: '/cntoai/model_management_pin_top.dspy',
|
||||
method: 'get',
|
||||
params: { id }
|
||||
})
|
||||
}
|
||||
// 下移
|
||||
export const reqModelBottom = (id) => {
|
||||
return request({
|
||||
url: '/cntoai/model_management_move_down.dspy',
|
||||
method: 'get',
|
||||
params: { id }
|
||||
})
|
||||
}
|
||||
|
||||
// apikey列表
|
||||
export const reqApikeyList = (params = {}) => {
|
||||
return request({
|
||||
url: '/cntoai/get_model_apikey.dspy',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 创建apikey
|
||||
export const reqCreateApikey = (params = {}) => {
|
||||
return request({
|
||||
url: '/cntoai/create_model_apikey.dspy',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取模型api文档
|
||||
export const reqModelApiDocument = (params = {}) => {
|
||||
return request({
|
||||
url: '/cntoai/get_model_api_doc.dspy',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
//模型体验多轮会话
|
||||
export const reqModelExperienceMultiRound = (params = {}) => {
|
||||
return request({
|
||||
url: '/cntoai/chat_send_stream.dspy',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 左侧历史对话
|
||||
export const reqModelExperienceLeftHistory = (params = {}) => {
|
||||
return request({
|
||||
url: '/cntoai/chat_session_list.dspy',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 历史对话信息
|
||||
export const reqModelExperienceHistoryInfo = (params = {}) => {
|
||||
return request({
|
||||
url: '/cntoai/chat_session_messages.dspy',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
// 删除历史对话
|
||||
export const reqModelExperienceDeleteHistory = (params = {}) => {
|
||||
return request({
|
||||
url: '/cntoai/chat_session_delete.dspy',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@ -101,3 +101,12 @@ export const todoCount = () => {
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 获取token市集
|
||||
export const reqTokenMarket = () => {
|
||||
return request({
|
||||
url: '/cntoai/model_management_customer_search.dspy',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:visible.sync="dialogVisible"
|
||||
custom-class="listing-confirm-dialog"
|
||||
width="380px"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
>
|
||||
<div class="listing-confirm-content" :class="action === 'up' ? 'is-up' : 'is-down'">
|
||||
<div class="listing-confirm-icon">
|
||||
<i :class="action === 'up' ? 'el-icon-check' : 'el-icon-warning-outline'"></i>
|
||||
</div>
|
||||
<div class="listing-confirm-main">
|
||||
<h3>{{ action === 'up' ? '确认上架' : '确认下架' }}</h3>
|
||||
<p v-if="action === 'up'">确认上架该模型到Token市集?</p>
|
||||
<p v-else>确认下架模型后,该模型将从Token市集中移除,用户将无法继续使用。</p>
|
||||
<div class="listing-confirm-model">
|
||||
模型名称:<strong>{{ getModelDisplayName(model || {}) }}</strong>
|
||||
</div>
|
||||
<div class="listing-confirm-actions">
|
||||
<el-button class="listing-cancel-btn" @click="$emit('close')">取消</el-button>
|
||||
<el-button
|
||||
class="listing-submit-btn"
|
||||
:class="action === 'up' ? 'is-up' : 'is-down'"
|
||||
:loading="loading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ action === 'up' ? '确认上架' : '确认下架' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ListingConfirmDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
default: 'up'
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dialogVisible: {
|
||||
get() {
|
||||
return this.visible
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:visible', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getModelDisplayName(row) {
|
||||
return row.display_name || row.model_name || '-'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.listing-confirm-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: 0 !important;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 14px 42px rgba(15, 23, 42, 0.24);
|
||||
overflow: hidden;
|
||||
|
||||
.el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-confirm-content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 22px 26px 18px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-confirm-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-confirm-content.is-up .listing-confirm-icon {
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-confirm-content.is-down .listing-confirm-icon {
|
||||
color: #d97706;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-confirm-main {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 5px 0 20px;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-confirm-model {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
|
||||
strong {
|
||||
margin-left: 6px;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-cancel-btn {
|
||||
min-width: 64px;
|
||||
height: 32px;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
background: #f3f4f6;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.listing-confirm-dialog .listing-submit-btn {
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
&.is-up {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
&.is-down {
|
||||
background: #eab308;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
257
f/web-kboss/src/components/modelManagement/ModelDetailDialog.vue
Normal file
257
f/web-kboss/src/components/modelManagement/ModelDetailDialog.vue
Normal file
@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:visible.sync="dialogVisible"
|
||||
custom-class="model-detail-dialog"
|
||||
width="760px"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
>
|
||||
<div slot="title" class="detail-dialog-title">
|
||||
<span>模型详情</span>
|
||||
<i class="el-icon-close" @click="dialogVisible = false"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="model" class="detail-content">
|
||||
<div class="detail-head">
|
||||
<div>
|
||||
<h2>{{ getModelDisplayName(model) }}</h2>
|
||||
<p>模型ID: {{ model.id || '-' }}</p>
|
||||
</div>
|
||||
<el-tag
|
||||
effect="light"
|
||||
class="detail-status"
|
||||
:class="Number(model.listing_status) === 1 ? 'is-listed' : 'is-pending'"
|
||||
>
|
||||
{{ getDetailStatusText(model.listing_status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-card">
|
||||
<span>模型类型</span>
|
||||
<strong>{{ model.model_type || '-' }}</strong>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<span>供应商</span>
|
||||
<strong>{{ model.provider || '-' }}</strong>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<span>计费方式</span>
|
||||
<strong>{{ model.billing_method || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-price">
|
||||
<h3>价格信息</h3>
|
||||
<div class="detail-price-list">
|
||||
<div>
|
||||
<span>输入</span>
|
||||
<strong>¥{{ formatPrice(model.input_token_price) }}/千Token</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>输出</span>
|
||||
<strong>¥{{ formatPrice(model.output_token_price) }}/千Token</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-footer">
|
||||
更新时间: {{ model.updated_at || model.created_at || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ModelDetailDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dialogVisible: {
|
||||
get() {
|
||||
return this.visible
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:visible', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getModelDisplayName(row) {
|
||||
return row.display_name || row.model_name || '-'
|
||||
},
|
||||
getDetailStatusText(status) {
|
||||
return Number(status) === 1 ? '已上架' : '已上传'
|
||||
},
|
||||
formatPrice(value) {
|
||||
return Number(value || 0).toFixed(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.model-detail-dialog {
|
||||
margin-top: 6vh !important;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 72px rgba(15, 23, 42, 0.26);
|
||||
overflow: hidden;
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 26px 34px;
|
||||
border-bottom: 1px solid #eef0f4;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-dialog-title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #1f2937;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
|
||||
i {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-content {
|
||||
padding: 34px 34px 28px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 10px;
|
||||
color: #1f2937;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #7a8494;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-status {
|
||||
min-width: 72px;
|
||||
height: 34px;
|
||||
padding: 0 18px;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
border-radius: 999px;
|
||||
|
||||
&.is-listed {
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
border-color: #dcfce7;
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
color: #b45309;
|
||||
background: #fff1d6;
|
||||
border-color: #fff1d6;
|
||||
}
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px 22px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-card {
|
||||
min-height: 84px;
|
||||
padding: 20px 22px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #f4f6f9;
|
||||
border-radius: 10px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #7a8494;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #1f2937;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-price {
|
||||
padding: 22px;
|
||||
margin-bottom: 26px;
|
||||
background: linear-gradient(135deg, #eaf4ff 0%, #f1f6ff 100%);
|
||||
border: 1px solid #e2efff;
|
||||
border-radius: 10px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
color: #2f7dcc;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-price-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 170px);
|
||||
gap: 18px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: #7a8494;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #1f2937;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
.model-detail-dialog .detail-footer {
|
||||
padding-top: 18px;
|
||||
color: #7a8494;
|
||||
border-top: 1px solid #edf0f3;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
134
f/web-kboss/src/components/modelManagement/ModelFilter.vue
Normal file
134
f/web-kboss/src/components/modelManagement/ModelFilter.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<el-card class="model-toolbar" shadow="never">
|
||||
<div class="toolbar-left">
|
||||
<h3>筛选模型</h3>
|
||||
<p>按模型名称和类型快速定位目标模型。</p>
|
||||
</div>
|
||||
<el-form class="toolbar-search" :model="searchForm" inline>
|
||||
<el-form-item label="模型名称">
|
||||
<el-input
|
||||
v-model="searchForm.name"
|
||||
clearable
|
||||
size="small"
|
||||
prefix-icon="el-icon-search"
|
||||
placeholder="请输入模型名称"
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型类型">
|
||||
<el-select
|
||||
v-model="searchForm.type"
|
||||
clearable
|
||||
filterable
|
||||
size="small"
|
||||
placeholder="请选择模型类型"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in modelTypeOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="供应商">
|
||||
<el-select
|
||||
v-model="searchForm.provider"
|
||||
clearable
|
||||
filterable
|
||||
size="small"
|
||||
placeholder="请选择供应商"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in providerOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="small" icon="el-icon-search" @click="$emit('search')">查询</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh-left" @click="$emit('reset')">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ModelFilter',
|
||||
props: {
|
||||
searchForm: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
modelTypeOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
providerOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-toolbar {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
|
||||
|
||||
/deep/ .el-card__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 20px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 6px;
|
||||
color: #1f2d3d;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #8a94a6;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 16px;
|
||||
|
||||
/deep/ .el-input__inner {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/deep/ .el-button {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.model-toolbar /deep/ .el-card__body {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
f/web-kboss/src/components/modelManagement/ModelStats.vue
Normal file
126
f/web-kboss/src/components/modelManagement/ModelStats.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="model-stat-list">
|
||||
<div
|
||||
v-for="item in stats"
|
||||
:key="item.label"
|
||||
class="model-stat-item"
|
||||
:class="item.className"
|
||||
>
|
||||
<div class="stat-icon">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ModelStats',
|
||||
props: {
|
||||
stats: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-stat-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.model-stat-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 34%;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -28px;
|
||||
top: -28px;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
background: rgba(64, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
&.success::after {
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
}
|
||||
|
||||
&.warning::after {
|
||||
background: rgba(230, 162, 60, 0.12);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 46px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: #409eff;
|
||||
font-size: 22px;
|
||||
background: #eef5ff;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
&.success .stat-icon {
|
||||
color: #67c23a;
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
&.warning .stat-icon {
|
||||
color: #e6a23c;
|
||||
background: #fdf6ec;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #303133;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.model-stat-list {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-stat-item {
|
||||
width: calc(50% - 8px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.model-stat-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -731,11 +731,17 @@ export default {
|
||||
},
|
||||
async logout() {
|
||||
store.commit('tagsView/resetBreadcrumbState');
|
||||
store.commit('permission/RESET_ROUTES');
|
||||
sessionStorage.removeItem("auths");
|
||||
sessionStorage.removeItem("routes");
|
||||
sessionStorage.removeItem("user");
|
||||
sessionStorage.removeItem("userId");
|
||||
sessionStorage.removeItem("org_type")
|
||||
sessionStorage.removeItem("userType");
|
||||
sessionStorage.removeItem("orgType");
|
||||
sessionStorage.removeItem("roles");
|
||||
sessionStorage.removeItem("juese");
|
||||
sessionStorage.removeItem("jueseNew");
|
||||
localStorage.removeItem('userId')
|
||||
localStorage.removeItem("auths");
|
||||
localStorage.removeItem("routes");
|
||||
@ -752,10 +758,16 @@ export default {
|
||||
let url = window.location.href;
|
||||
await this.$router.push(`/login`);
|
||||
store.commit('tagsView/resetBreadcrumbState');
|
||||
store.commit('permission/RESET_ROUTES');
|
||||
sessionStorage.removeItem("auths");
|
||||
sessionStorage.removeItem("routes");
|
||||
sessionStorage.removeItem("user");
|
||||
sessionStorage.removeItem("userId");
|
||||
sessionStorage.removeItem("userType");
|
||||
sessionStorage.removeItem("orgType");
|
||||
sessionStorage.removeItem("roles");
|
||||
sessionStorage.removeItem("juese");
|
||||
sessionStorage.removeItem("jueseNew");
|
||||
},
|
||||
changeColor() {
|
||||
this.dialogFormVisible = false
|
||||
|
||||
@ -97,12 +97,12 @@ export default {
|
||||
// 给嵌套菜单添加左边距
|
||||
::v-deep .nest-menu {
|
||||
.el-menu-item {
|
||||
padding-left: 60px !important; // 或者您想要的任何值,比如10px
|
||||
padding-left: 42px !important; // 子菜单稍微缩进,同时保留蓝底圆角选中态
|
||||
}
|
||||
|
||||
// 如果还有更深层的嵌套,可以继续设置
|
||||
.nest-menu .el-menu-item {
|
||||
padding-left: 100px !important;
|
||||
padding-left: 64px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
:text-color="variables.menuText"
|
||||
:unique-opened="true"
|
||||
:active-text-color="variables.menuActiveText"
|
||||
:collapse-transition="false"
|
||||
:collapse-transition="true"
|
||||
:default-active="activeMenu"
|
||||
mode="vertical"
|
||||
class="el-menu-vertical"
|
||||
@ -25,6 +25,14 @@
|
||||
/>
|
||||
</el-menu>
|
||||
</happy-scroll>
|
||||
<button
|
||||
class="sidebar-collapse-btn"
|
||||
:class="{ collapsed: isCollapse }"
|
||||
:title="isCollapse ? '展开菜单' : '折叠菜单'"
|
||||
@click="toggleSideBar"
|
||||
>
|
||||
<i :class="isCollapse ? 'el-icon-s-unfold' : 'el-icon-s-fold'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -72,6 +80,12 @@ export default {
|
||||
|
||||
mounted() {
|
||||
console.log("Sidebar mounted - 权限路由:", this.permissionRoutes);
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleSideBar() {
|
||||
this.$store.dispatch("app/toggleSideBar");
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@ -88,6 +102,8 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.menu-scroll-container {
|
||||
@ -107,6 +123,36 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 18px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
color: #8a94a6;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 8px 22px rgba(31, 45, 61, 0.12);
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
color: #1e6fff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 26px rgba(30, 111, 255, 0.18);
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
right: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
// 更具体的选择器
|
||||
::v-deep .el-menu-vertical {
|
||||
border: none;
|
||||
@ -117,9 +163,37 @@ export default {
|
||||
|
||||
.el-submenu__title,
|
||||
.el-menu-item {
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
margin: 6px 14px;
|
||||
padding: 0 18px !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 12px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.el-submenu__title span,
|
||||
.el-menu-item span {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.el-submenu__title:hover,
|
||||
.el-menu-item:not(.is-active):hover {
|
||||
color: #1e6fff !important;
|
||||
background: #f4f8ff !important;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(135deg, #1e6fff, #5d8dff) !important;
|
||||
|
||||
}
|
||||
|
||||
.el-menu-item.is-active i,
|
||||
.el-menu-item.is-active span {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
// 子菜单容器
|
||||
@ -128,23 +202,18 @@ export default {
|
||||
.el-menu-item {
|
||||
// 激活的子菜单项
|
||||
&.is-active {
|
||||
background-color: #d7dafd !important;
|
||||
color: #296ad9 !important;
|
||||
background: linear-gradient(135deg, #1e6fff, #5d8dff) !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background-color: #296ad9;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 非激活状态的悬停效果
|
||||
&:not(.is-active):hover {
|
||||
background-color: #f5f7fa !important;
|
||||
background-color: #f4f8ff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -157,16 +226,24 @@ export default {
|
||||
|
||||
.el-submenu__title,
|
||||
.el-menu-item {
|
||||
margin: 6px 8px;
|
||||
padding: 0 !important;
|
||||
text-overflow: clip;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.el-submenu__title span,
|
||||
.el-menu-item span {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
|
||||
// 折叠状态下的子菜单激活样式
|
||||
.el-menu--popup {
|
||||
.el-menu-item {
|
||||
&.is-active {
|
||||
background-color: #f5f7fa !important;
|
||||
color: #296ad9 !important;
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(135deg, #1e6fff 0%, #244fbd 100%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import {getHomePath} from "@/views/setting/tools";
|
||||
|
||||
NProgress.configure({showSpinner: false}); // NProgress Configuration
|
||||
|
||||
const whiteList = ["/login", "/homePage", "/registrationPage", "/shoppingCart", "/homePageImage","/h5HomePage",'/H5about','/modelProductDetail','/ncmatchHome']; // no redirect whitelist
|
||||
const whiteList = ["product","/login", "/homePage", "/registrationPage", "/shoppingCart", "/homePageImage","/h5HomePage",'/H5about','/modelProductDetail','/ncmatchHome']; // no redirect whitelist
|
||||
|
||||
// 获取用户代理字符串
|
||||
const userAgent = window.navigator.userAgent;
|
||||
|
||||
@ -404,11 +404,36 @@ export const constantRoutes = [
|
||||
* 需要根据用户角色动态加载的路由
|
||||
*/
|
||||
export const asyncRoutes = [
|
||||
// 运营——模型管理
|
||||
{
|
||||
path: "/modelManagement",
|
||||
component: Layout,
|
||||
meta: {
|
||||
// title 是菜单上显示的文字,fullPath 用来和后端权限 path 对权限。
|
||||
title: "模型管理",
|
||||
fullPath: "/modelManagement",
|
||||
noCache: true,
|
||||
// icon 是左侧菜单图标,roles 限制只有运营角色能看到。
|
||||
icon: "el-icon-cpu",
|
||||
roles: ["运营"]
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: () => import('@/views/modelManagement/modelManagement.vue'),
|
||||
name: 'modelManagement',
|
||||
meta: {
|
||||
title: "模型管理",
|
||||
fullPath: "/modelManagement",
|
||||
noCache: true,
|
||||
roles: ["运营"]
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
|
||||
// 全部产品 - 一级菜单
|
||||
// 全部产品 - 一级菜单(无子路由)
|
||||
// token市集 - 一级菜单(所有登录用户都能看到)
|
||||
{
|
||||
path: "/product",
|
||||
component: Layout,
|
||||
@ -416,7 +441,7 @@ export const asyncRoutes = [
|
||||
title: "全部产品",
|
||||
fullPath: "/product",
|
||||
noCache: true,
|
||||
icon: "el-icon-goods"
|
||||
icon: "el-icon-coin"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@ -431,6 +456,66 @@ export const asyncRoutes = [
|
||||
},
|
||||
]
|
||||
},
|
||||
// 令牌管理 - 一级菜单(所有登录用户都能看到)
|
||||
{
|
||||
path: "/tokenManagement",
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: "令牌管理",
|
||||
fullPath: "/tokenManagement",
|
||||
noCache: true,
|
||||
icon: "el-icon-key"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: () => import('@/views/tokenManagement/index.vue'),
|
||||
name: 'TokenManagement',
|
||||
meta: {
|
||||
title: "令牌管理",
|
||||
fullPath: "/tokenManagement",
|
||||
noCache: true,
|
||||
icon: "el-icon-key"
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
// 模型体验
|
||||
{
|
||||
path: "/modelExperience",
|
||||
component: () => import('@/views/modelManagement/Experience.vue'),
|
||||
hidden: true,
|
||||
name: 'modelExperience',
|
||||
meta: {
|
||||
title: "模型体验",
|
||||
fullPath: "/modelExperience",
|
||||
noCache: true
|
||||
},
|
||||
},
|
||||
// 模型详情
|
||||
{
|
||||
path: "/modelDetail",
|
||||
component: () => import('@/views/modelManagement/ModelDetail.vue'),
|
||||
hidden: true,
|
||||
name: 'modelDetail',
|
||||
meta: {
|
||||
title: "模型详情",
|
||||
fullPath: "/modelDetail",
|
||||
noCache: true
|
||||
},
|
||||
},
|
||||
// API文档
|
||||
{
|
||||
path: "/modelApiDocument",
|
||||
component: () => import('@/views/modelManagement/ApiDocument.vue'),
|
||||
hidden: true,
|
||||
name: 'modelApiDocument',
|
||||
meta: {
|
||||
title: "API文档",
|
||||
fullPath: "/modelApiDocument",
|
||||
noCache: true
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/overview",
|
||||
component: Layout,
|
||||
@ -453,6 +538,32 @@ export const asyncRoutes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
// 运营——运营报表
|
||||
{
|
||||
path: "/operationReport",
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: "运营报表",
|
||||
fullPath: "/operationReport",
|
||||
noCache: true,
|
||||
icon: "el-icon-data-analysis",
|
||||
roles: ["运营"]
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: () => import('@/views/operation/operationReport/index.vue'),
|
||||
name: 'operationReport',
|
||||
meta: {
|
||||
title: "运营报表",
|
||||
fullPath: "/operationReport",
|
||||
noCache: true,
|
||||
icon: "el-icon-data-analysis",
|
||||
roles: ["运营"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
path: "/orderManagement",
|
||||
@ -565,13 +676,15 @@ export const asyncRoutes = [
|
||||
path: "/consultingMangement",
|
||||
name: 'ConsultingMangement',
|
||||
component: Layout,
|
||||
meta: { title: "咨询表单", fullPath: "/consultingMangement", noCache: true, icon: "el-icon-s-platform" },
|
||||
// 咨询表单是表单/订单类入口,所以菜单图标用 el-icon-s-order。
|
||||
meta: { title: "咨询表单", fullPath: "/consultingMangement", noCache: true, icon: "el-icon-s-order" },
|
||||
children: [
|
||||
{
|
||||
path: "index",
|
||||
component: () => import('@/views/operation/consultingMangement/index.vue'),
|
||||
name: 'ConsultingMangement',
|
||||
meta: { title: "咨询表单", fullPath: "/consultingMangement/index", noCache: true, icon: "el-icon-s-platform" },
|
||||
// 子路由也带 icon,单子菜单折叠成一级菜单时能继续显示图标。
|
||||
meta: { title: "咨询表单", fullPath: "/consultingMangement/index", noCache: true, icon: "el-icon-s-order" },
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -1180,7 +1293,8 @@ export const asyncRoutes = [
|
||||
component: Layout,
|
||||
name: "qualificationReview",
|
||||
redirect: "/qualificationReview/index",
|
||||
meta: { fullPath: "/qualificationReview", title: "资质审核", noCache: true, icon: 'el-icon-s-home' },
|
||||
// 资质审核是审核/校验类菜单,所以用 el-icon-s-check。
|
||||
meta: { fullPath: "/qualificationReview", title: "资质审核", noCache: true, icon: 'el-icon-s-check' },
|
||||
children: [
|
||||
{
|
||||
path: "noApproveInfo",
|
||||
@ -1203,7 +1317,8 @@ export const asyncRoutes = [
|
||||
component: Layout,
|
||||
name: "approveMangement",
|
||||
redirect: "/approveMangement/index",
|
||||
meta: { fullPath: "/approveMangement", title: "供需审核", noCache: true, icon: 'el-icon-s-home' },
|
||||
// 供需审核表示供给和需求两边协作审核,所以用 el-icon-s-cooperation。
|
||||
meta: { fullPath: "/approveMangement", title: "供需审核", noCache: true, icon: 'el-icon-s-cooperation' },
|
||||
children: [
|
||||
{
|
||||
path: "pendingPro",
|
||||
@ -1263,12 +1378,14 @@ export const asyncRoutes = [
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
path: "/menuMangement",
|
||||
component: Layout,
|
||||
name: "menuMangement",
|
||||
redirect: "/menuMangement/index",
|
||||
meta: { fullPath: "/menuMangement", title: "菜单管理", noCache: true, icon: 'el-icon-s-home' },
|
||||
// 菜单管理就是维护菜单配置,用 Element UI 的菜单图标。
|
||||
meta: { fullPath: "/menuMangement", title: "菜单管理", noCache: true, icon: 'el-icon-menu' },
|
||||
children: [
|
||||
{
|
||||
path: "index",
|
||||
@ -1435,7 +1552,8 @@ export const asyncRoutes = [
|
||||
|
||||
{
|
||||
path: "/operation", component: Layout, redirect: "/operation/supplierManagement", meta: {
|
||||
title: "运营", icon: "el-icon-s-tools", noCache: true, fullPath: "/operation",
|
||||
// 运营是运营后台入口,用操作/运营类图标。
|
||||
title: "运营", icon: "el-icon-s-operation", noCache: true, fullPath: "/operation",
|
||||
}, children: [
|
||||
|
||||
{
|
||||
@ -1559,7 +1677,7 @@ export const asyncRoutes = [
|
||||
hidden: true,
|
||||
path: "colony",
|
||||
|
||||
title: "管理集群",
|
||||
title: "管理集群",
|
||||
component: () => import("@/views/operation/computingCenterManagement/colony/index.vue"),
|
||||
name: "supplierManagement",
|
||||
meta: {
|
||||
|
||||
@ -1,24 +1,197 @@
|
||||
// permission.js - 修改后的完整代码
|
||||
import { asyncRoutes, constantRoutes } from "@/router";
|
||||
|
||||
// 获取用户代理字符串
|
||||
const userAgent = window.navigator.userAgent;
|
||||
// 用浏览器 UA 判断当前是不是手机端,后面会按 PC / 手机过滤菜单。
|
||||
const MOBILE_UA_REGEXP = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||
|
||||
// 判断是否为移动设备
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
|
||||
// 项目里用到的固定角色名,集中放这里,避免代码里到处写字符串。
|
||||
const CUSTOMER_ROLE = '客户';
|
||||
const OPERATION_ROLE = '运营';
|
||||
|
||||
// 如果是移动设备,添加移动端首页路由和根路径重定向
|
||||
// 这个用户能看到订单管理里的特殊子菜单,比如历史订单和订单详情。
|
||||
const SPECIAL_ORDER_USER = 'ZhipuHZ';
|
||||
|
||||
// 超级管理员只放行这个一级菜单。
|
||||
const SUPER_ADMIN_ROUTE_PATH = '/superAdministrator';
|
||||
|
||||
// 所有登录用户都能访问的公共路由,不依赖后端 auths 和角色。hidden 路由不会显示在菜单里。
|
||||
const COMMON_ROUTE_PATHS = ['/product', '/tokenManagement', '/modelExperience', '/modelDetail', '/modelApiDocument'];
|
||||
|
||||
// 运营角色需要额外补出来的菜单。
|
||||
const OPERATION_EXTRA_ROUTE_PATHS = ['/modelManagement', '/operationReport'];
|
||||
|
||||
// 普通客户账号默认要补出来的基础菜单。
|
||||
const BASE_USER_ROUTE_PATHS = ['/orderManagement', '/resourceManagement'];
|
||||
|
||||
// 客户角色额外能看到的一级菜单。
|
||||
const CUSTOMER_EXTRA_ROUTE_PATHS = [
|
||||
'/unsubscribeManagement',
|
||||
'/informationPerfect',
|
||||
'/rechargeManagement',
|
||||
'/invoiceManagement',
|
||||
'/workOrderManagement'
|
||||
];
|
||||
|
||||
// 这些菜单只允许客户角色看到,非客户就算后端给了权限也不展示。
|
||||
const CUSTOMER_ONLY_ROUTE_PATHS = [
|
||||
'/overview',
|
||||
...CUSTOMER_EXTRA_ROUTE_PATHS
|
||||
];
|
||||
|
||||
// 客户登录后必须能看到的入口菜单,不完全依赖后端 auths 返回。
|
||||
const CUSTOMER_ALWAYS_VISIBLE_ROUTE_PATHS = ['/overview'];
|
||||
|
||||
// 订单管理里只给 SPECIAL_ORDER_USER 看的子菜单 path。
|
||||
const ORDER_CHILDREN_ONLY_FOR_SPECIAL_USER = ['HistoricalOrders', 'orderDetails'];
|
||||
|
||||
const isMobile = MOBILE_UA_REGEXP.test(window.navigator.userAgent);
|
||||
|
||||
// 把角色统一整理成数组,兼容 undefined、数组、逗号字符串这几种写法。
|
||||
function normalizeRoles(roles) {
|
||||
if (!roles) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(roles)) {
|
||||
return roles;
|
||||
}
|
||||
|
||||
if (typeof roles === 'string') {
|
||||
return roles.split(',').filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// 从 sessionStorage 里取 roles,取不到或格式坏了就当成没有角色。
|
||||
function getSessionRoles() {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem('roles') || '[]');
|
||||
} catch (error) {
|
||||
console.warn('读取 roles 失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总当前用户的所有角色来源:接口参数、vuex、sessionStorage、新旧字段。
|
||||
function getCurrentRoles(params, rootState) {
|
||||
return [
|
||||
...normalizeRoles(params.roles),
|
||||
...normalizeRoles(rootState.user.roles),
|
||||
...normalizeRoles(getSessionRoles()),
|
||||
...normalizeRoles(sessionStorage.getItem('jueseNew'))
|
||||
];
|
||||
}
|
||||
|
||||
// 判断当前用户是不是客户角色。
|
||||
function isCustomer(userRoles = []) {
|
||||
return userRoles.includes(CUSTOMER_ROLE);
|
||||
}
|
||||
|
||||
// 把布尔值转成更好读的设备类型,后面的判断都用 pc / mobile。
|
||||
function getDeviceType(isMobileDevice) {
|
||||
return isMobileDevice ? 'mobile' : 'pc';
|
||||
}
|
||||
|
||||
// 判断路由 meta.roles 是否满足。没写 roles 的路由默认所有角色都能继续往下判断。
|
||||
function hasRouteRole(route, userRoles = []) {
|
||||
const routeRoles = route.meta?.roles;
|
||||
|
||||
if (!routeRoles || routeRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return routeRoles.some(role => userRoles.includes(role));
|
||||
}
|
||||
|
||||
// 根据设备过滤路由:手机只要手机路由,PC 不要手机专用路由。
|
||||
function isRouteAllowedByDevice(route, deviceType) {
|
||||
if (deviceType === 'mobile') {
|
||||
return route.meta?.isMobile || route.meta?.isMobile === true;
|
||||
}
|
||||
|
||||
return route.meta?.isMobile !== true;
|
||||
}
|
||||
|
||||
// 在一组路由里按 path 找某个路由。
|
||||
function findRouteByPath(routes, path) {
|
||||
return routes.find(route => route.path === path);
|
||||
}
|
||||
|
||||
// 后端 auths 里的 path 要和路由 meta.fullPath 对上,对上才算有权限。
|
||||
function routeHasPermission(route, permissions) {
|
||||
return permissions.some(permission => permission.path === route.meta?.fullPath);
|
||||
}
|
||||
|
||||
// 客户专属菜单要再卡一层客户角色,防止非客户误展示。
|
||||
function canShowCustomerOnlyRoute(route, userRoles) {
|
||||
return !CUSTOMER_ONLY_ROUTE_PATHS.includes(route.path) || isCustomer(userRoles);
|
||||
}
|
||||
|
||||
// 把所有动态路由的 fullPath 收集出来。后端返回 path 为空时,表示拥有全部权限。
|
||||
function getAllRoutePermissions(routes) {
|
||||
const permissions = [];
|
||||
|
||||
routes.forEach(route => {
|
||||
if (route.meta?.fullPath) {
|
||||
permissions.push({ path: route.meta.fullPath });
|
||||
}
|
||||
|
||||
if (route.children) {
|
||||
permissions.push(...getAllRoutePermissions(route.children));
|
||||
}
|
||||
});
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
// 复制路由对象。这里不能用 JSON 深拷贝,因为路由里的 component 是函数,会被 JSON 丢掉。
|
||||
function cloneRoute(route) {
|
||||
const clonedRoute = { ...route };
|
||||
|
||||
if (route.meta) {
|
||||
clonedRoute.meta = { ...route.meta };
|
||||
}
|
||||
|
||||
if (route.children) {
|
||||
clonedRoute.children = route.children.map(cloneRoute);
|
||||
}
|
||||
|
||||
return clonedRoute;
|
||||
}
|
||||
|
||||
// 根据 path 列表批量找到对应路由,没找到的自动过滤掉。
|
||||
function getRoutesByPath(routes, paths) {
|
||||
return paths
|
||||
.map(path => findRouteByPath(routes, path))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// 判断这个一级路由是不是已经加过了,避免菜单重复出现。
|
||||
function shouldAppendRoute(accessedRoutes, route) {
|
||||
return !accessedRoutes.some(item => item.path === route.path);
|
||||
}
|
||||
|
||||
// 把缺少的路由补到最终菜单里,补之前会先去重并复制一份。
|
||||
function appendMissingRoutes(accessedRoutes, routesToAppend) {
|
||||
routesToAppend.forEach(route => {
|
||||
if (shouldAppendRoute(accessedRoutes, route)) {
|
||||
accessedRoutes.push(cloneRoute(route));
|
||||
}
|
||||
});
|
||||
|
||||
return accessedRoutes;
|
||||
}
|
||||
|
||||
// 如果是手机访问,额外把根路径导到 H5 首页,并注册 H5 首页菜单。
|
||||
if (isMobile) {
|
||||
console.log("检测到移动设备,添加移动端路由");
|
||||
|
||||
// 先添加根路径重定向到移动端首页
|
||||
constantRoutes.unshift({
|
||||
path: '/',
|
||||
redirect: '/h5HomePage',
|
||||
hidden: true
|
||||
});
|
||||
|
||||
// 添加移动端首页路由
|
||||
constantRoutes.push({
|
||||
path: '/h5HomePage',
|
||||
name: 'H5HomePage',
|
||||
@ -82,106 +255,165 @@ if (isMobile) {
|
||||
});
|
||||
}
|
||||
|
||||
// 修复:更全面的路由过滤逻辑
|
||||
// 核心过滤函数:拿后端权限、角色和设备类型,一层层筛出最终可访问路由。
|
||||
function filterAsyncRoutes(routes, permissions, userRoles = [], deviceType = 'pc') {
|
||||
const res = [];
|
||||
|
||||
// 定义需要客户角色才能访问的路由
|
||||
const customerOnlyRoutes = [
|
||||
"/product", "/overview", "/workOrderManagement",
|
||||
"/unsubscribeManagement", "/informationPerfect",
|
||||
"/rechargeManagement", "/invoiceManagement"
|
||||
];
|
||||
|
||||
routes.forEach(route => {
|
||||
// 创建路由副本
|
||||
const tmpRoute = { ...route };
|
||||
// 先复制一份,避免直接改原始 asyncRoutes。
|
||||
const tmpRoute = cloneRoute(route);
|
||||
|
||||
// 检查当前路由是否在权限列表中
|
||||
const hasPermission = permissions.some(p => p.path === route.meta?.fullPath);
|
||||
|
||||
// 特殊处理:确保"全部产品"和"资源概览"这两个一级路由在客户角色下显示
|
||||
const isCriticalRoute = route.path === "/product" || route.path === "/overview";
|
||||
|
||||
// 检查是否为仅客户可访问的路由
|
||||
const isCustomerOnlyRoute = customerOnlyRoutes.includes(route.path);
|
||||
|
||||
// 如果路由需要客户角色,但用户不是客户,则跳过
|
||||
if (isCustomerOnlyRoute && !userRoles.includes('客户')) {
|
||||
return; // 跳过当前路由
|
||||
// 第一步:角色不符合,或者客户专属菜单但当前用户不是客户,直接跳过。
|
||||
if (!hasRouteRole(tmpRoute, userRoles) || !canShowCustomerOnlyRoute(route, userRoles)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 新增:根据设备类型过滤路由
|
||||
if (deviceType === 'mobile' && !(route.meta?.isMobile || route.meta?.isMobile === true)) {
|
||||
return; // 移动设备跳过非移动端路由
|
||||
}
|
||||
if (deviceType === 'pc' && route.meta?.isMobile === true) {
|
||||
return; // PC设备跳过移动端路由
|
||||
// 第二步:设备不符合也跳过,比如 PC 端不展示 H5 专用路由。
|
||||
if (!isRouteAllowedByDevice(route, deviceType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果当前路由有权限,则加入结果
|
||||
if (hasPermission) {
|
||||
// 第三步:看后端 auths 里有没有当前路由的 fullPath。
|
||||
const hasPermission = routeHasPermission(route, permissions);
|
||||
|
||||
// 第四步:客户首页入口特殊处理,客户登录后默认展示。
|
||||
const isAlwaysVisibleCustomerRoute =
|
||||
CUSTOMER_ALWAYS_VISIBLE_ROUTE_PATHS.includes(route.path) && isCustomer(userRoles);
|
||||
|
||||
// 有权限,或者是客户默认入口,就把这个路由放进最终菜单。
|
||||
if (hasPermission || isAlwaysVisibleCustomerRoute) {
|
||||
res.push(tmpRoute);
|
||||
}
|
||||
// 如果是关键路由且用户是客户,也要加入结果
|
||||
else if (isCriticalRoute && userRoles.includes('客户')) {
|
||||
res.push(tmpRoute);
|
||||
}
|
||||
// 如果没有直接权限,但有子路由,递归处理子路由
|
||||
else if (tmpRoute.children) {
|
||||
} else if (tmpRoute.children) {
|
||||
// 父级没权限时继续看子级。只要子级有权限,父级也要保留,否则子菜单没地方挂。
|
||||
const filteredChildren = filterAsyncRoutes(tmpRoute.children, permissions, userRoles, deviceType);
|
||||
|
||||
if (filteredChildren.length > 0) {
|
||||
tmpRoute.children = filteredChildren;
|
||||
res.push(tmpRoute); // 即使父路由本身没有权限,只要有子路由有权限,也要保留父路由
|
||||
res.push(tmpRoute);
|
||||
}
|
||||
}
|
||||
// 如果当前路由既没有权限,也没有有权限的子路由,则不添加到结果中
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// 新增:为普通用户添加订单管理和资源管理路由
|
||||
// 给普通用户和客户补充固定菜单:订单、资源,以及客户专属的工单/充值/发票等。
|
||||
function addUserRoutes(routes, userType, orgType, userRoles = [], deviceType = 'pc') {
|
||||
console.log("addUserRoutes - userType:", userType, "orgType:", orgType, "userRoles:", userRoles);
|
||||
|
||||
const userRoutes = [];
|
||||
|
||||
// 修复:包含 orgType 为 2 和 3 的情况(公司客户和个人客户)
|
||||
if (userType === 'user' || orgType == 2 || orgType == 3) {
|
||||
const orderManagementRoute = routes.find(route => route.path === "/orderManagement");
|
||||
const resourceManagementRoute = routes.find(route => route.path === "/resourceManagement");
|
||||
// orgType 为 2 或 3 时也按客户账号处理。
|
||||
const isUserAccount = userType === 'user' || orgType == 2 || orgType == 3;
|
||||
|
||||
// 新增:根据设备类型过滤
|
||||
if (orderManagementRoute && (deviceType === 'pc' || orderManagementRoute.meta?.isMobile === true)) {
|
||||
console.log("添加订单管理路由");
|
||||
userRoutes.push(JSON.parse(JSON.stringify(orderManagementRoute))); // 深拷贝
|
||||
}
|
||||
if (isUserAccount) {
|
||||
// 普通客户账号默认补订单管理和资源管理。
|
||||
const baseUserRoutes = getRoutesByPath(routes, BASE_USER_ROUTE_PATHS)
|
||||
.filter(route => isRouteAllowedByDevice(route, deviceType));
|
||||
|
||||
if (resourceManagementRoute && (deviceType === 'pc' || resourceManagementRoute.meta?.isMobile === true)) {
|
||||
console.log("添加资源管理路由");
|
||||
userRoutes.push(JSON.parse(JSON.stringify(resourceManagementRoute))); // 深拷贝
|
||||
}
|
||||
console.log("添加基础用户菜单路由:", baseUserRoutes.map(route => route.path));
|
||||
userRoutes.push(...baseUserRoutes);
|
||||
}
|
||||
|
||||
// 新增:为所有用户添加五个新的客户菜单,但只有客户角色才能看到
|
||||
const newCustomerRoutes = [
|
||||
routes.find(route => route.path === "/unsubscribeManagement"),
|
||||
routes.find(route => route.path === "/informationPerfect"),
|
||||
routes.find(route => route.path === "/rechargeManagement"),
|
||||
routes.find(route => route.path === "/invoiceManagement"),
|
||||
routes.find(route => route.path === "/workOrderManagement")
|
||||
].filter(route => {
|
||||
// 过滤掉undefined,并且只有客户角色才能看到这些路由
|
||||
return route && userRoles.includes('客户') &&
|
||||
(deviceType === 'pc' || route.meta?.isMobile === true);
|
||||
});
|
||||
if (isCustomer(userRoles)) {
|
||||
// 只有客户角色才补客户专属菜单。
|
||||
const customerRoutes = getRoutesByPath(routes, CUSTOMER_EXTRA_ROUTE_PATHS)
|
||||
.filter(route => isRouteAllowedByDevice(route, deviceType));
|
||||
|
||||
console.log("添加新的客户菜单路由:", newCustomerRoutes.map(r => r.path));
|
||||
userRoutes.push(...newCustomerRoutes);
|
||||
console.log("添加客户菜单路由:", customerRoutes.map(route => route.path));
|
||||
userRoutes.push(...customerRoutes);
|
||||
}
|
||||
|
||||
return userRoutes;
|
||||
}
|
||||
|
||||
// 运营角色额外补模型管理菜单,目前只在 PC 端展示。
|
||||
function addOperationRoutes(accessedRoutes, routes, userRoles = [], deviceType = 'pc') {
|
||||
if (!userRoles.includes(OPERATION_ROLE) || deviceType !== 'pc') {
|
||||
return accessedRoutes;
|
||||
}
|
||||
|
||||
return appendMissingRoutes(accessedRoutes, getRoutesByPath(routes, OPERATION_EXTRA_ROUTE_PATHS));
|
||||
}
|
||||
|
||||
// token市集是公共菜单,所有登录用户都要能看到。
|
||||
function addCommonRoutes(accessedRoutes, routes, deviceType = 'pc') {
|
||||
const commonRoutes = getRoutesByPath(routes, COMMON_ROUTE_PATHS)
|
||||
.filter(route => isRouteAllowedByDevice(route, deviceType));
|
||||
|
||||
return appendMissingRoutes(accessedRoutes, commonRoutes);
|
||||
}
|
||||
|
||||
// 订单管理有两个特殊子菜单,只有 SPECIAL_ORDER_USER 能看到,其他用户过滤掉。
|
||||
function filterOrderChildrenByUser(routes, username) {
|
||||
if (username === SPECIAL_ORDER_USER) {
|
||||
console.log(`用户 ${username} 是 ${SPECIAL_ORDER_USER},保留所有订单子路由`);
|
||||
return routes;
|
||||
}
|
||||
|
||||
return routes.map(route => {
|
||||
const nextRoute = cloneRoute(route);
|
||||
|
||||
// 找到订单管理后,移除特殊用户专属的子菜单。
|
||||
if (nextRoute.path === '/orderManagement' && nextRoute.children) {
|
||||
console.log(`用户 ${username} 不是 ${SPECIAL_ORDER_USER},过滤订单管理子路由`);
|
||||
nextRoute.children = nextRoute.children.filter(child =>
|
||||
!ORDER_CHILDREN_ONLY_FOR_SPECIAL_USER.includes(child.path)
|
||||
);
|
||||
console.log('过滤后订单子路由:', nextRoute.children.map(child => child.path));
|
||||
}
|
||||
|
||||
if (nextRoute.children) {
|
||||
// 子路由里如果还有订单管理,也继续递归处理。
|
||||
nextRoute.children = filterOrderChildrenByUser(nextRoute.children, username);
|
||||
}
|
||||
|
||||
return nextRoute;
|
||||
});
|
||||
}
|
||||
|
||||
// 整理后端权限列表。如果包含空 path,就按“拥有全部动态路由权限”处理。
|
||||
function getPermissionList(auths = []) {
|
||||
const permissions = JSON.parse(JSON.stringify(auths));
|
||||
const permissionPaths = permissions.map(item => item.path);
|
||||
|
||||
if (permissionPaths.includes('')) {
|
||||
return getAllRoutePermissions(asyncRoutes);
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
// 根据后端 auths 生成第一版可访问路由。没有 auths 就不展示动态菜单。
|
||||
function getAccessedRoutesByPermission(auths, userRoles, deviceType) {
|
||||
if (!auths.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const permissions = getPermissionList(auths);
|
||||
return filterAsyncRoutes(asyncRoutes, permissions, userRoles, deviceType);
|
||||
}
|
||||
|
||||
// 判断是不是超级管理员账号:用户名包含 admin,并且不是客户组织。
|
||||
function isSuperAdminUser(username, orgType) {
|
||||
return username && username.includes('admin') && orgType != 2 && orgType != 3;
|
||||
}
|
||||
|
||||
// 超级管理员只拿超级管理员菜单;手机端不展示这个菜单。
|
||||
function getSuperAdminRoutes(deviceType) {
|
||||
if (deviceType !== 'pc') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getRoutesByPath(asyncRoutes, [SUPER_ADMIN_ROUTE_PATH]).map(cloneRoute);
|
||||
}
|
||||
|
||||
// 在已有权限菜单基础上,补充用户类型/客户角色需要固定展示的菜单。
|
||||
function addUserSpecificRoutes(accessedRoutes, userType, orgType, userRoles, deviceType) {
|
||||
const userSpecificRoutes = addUserRoutes(asyncRoutes, userType, orgType, userRoles, deviceType);
|
||||
return appendMissingRoutes(accessedRoutes, userSpecificRoutes);
|
||||
}
|
||||
|
||||
const state = {
|
||||
routes: [],
|
||||
addRoutes: [],
|
||||
@ -192,12 +424,19 @@ const state = {
|
||||
const mutations = {
|
||||
SET_ROUTES: (state, routes) => {
|
||||
console.log("MUTATION SET_ROUTES - received routes:", routes);
|
||||
// addRoutes 只保存动态生成的菜单,方便 router.addRoutes 使用。
|
||||
state.addRoutes = routes;
|
||||
sessionStorage.setItem("routes", JSON.stringify(routes));
|
||||
// 将移动端首页路由也包含在内
|
||||
// routes 是侧边栏最终读取的数据:基础路由 + 动态权限路由。
|
||||
state.routes = constantRoutes.concat(routes);
|
||||
console.log("MUTATION SET_ROUTES - final state.routes:", state.routes);
|
||||
},
|
||||
RESET_ROUTES: (state) => {
|
||||
// 退出登录或切换账号时,必须清掉内存里的旧菜单,否则不刷新页面会继续显示上个角色的菜单。
|
||||
state.routes = [];
|
||||
state.addRoutes = [];
|
||||
sessionStorage.removeItem("routes");
|
||||
},
|
||||
SETUSERS: (state, user) => {
|
||||
state.users = user;
|
||||
},
|
||||
@ -226,131 +465,46 @@ const actions = {
|
||||
generateRoutes({ commit, rootState, state }, params) {
|
||||
console.log("ACTION generateRoutes - params:", params);
|
||||
return new Promise((resolve) => {
|
||||
let accessedRoutes;
|
||||
|
||||
// 从参数或sessionStorage中获取用户类型和组织类型
|
||||
// 1. 先拿到用户基础信息,优先用传进来的参数,没有就从 sessionStorage / vuex 兜底。
|
||||
const userType = params.userType || sessionStorage.getItem('userType') || '';
|
||||
const orgType = params.orgType || parseInt(sessionStorage.getItem('orgType')) || 0;
|
||||
|
||||
// 获取用户角色(从store或sessionStorage)
|
||||
const userRoles = rootState.user.roles || JSON.parse(sessionStorage.getItem('roles') || '[]');
|
||||
console.log("用户角色:", userRoles);
|
||||
|
||||
// 获取用户名
|
||||
const username = params.user || rootState.user.user || '';
|
||||
console.log("当前用户名:", username, "检查是否是ZhipuHZ:", username === 'ZhipuHZ');
|
||||
const userRoles = getCurrentRoles(params, rootState);
|
||||
const deviceType = getDeviceType(state.isMobile);
|
||||
const auths = params.auths ? JSON.parse(JSON.stringify(params.auths)) : [];
|
||||
|
||||
console.log("用户类型:", userType, "orgType:", orgType);
|
||||
// 2. 判断是不是超级管理员,超级管理员走单独菜单逻辑。
|
||||
const isSuperAdmin = isSuperAdminUser(params.user, orgType);
|
||||
|
||||
// 确定设备类型
|
||||
const deviceType = state.isMobile ? 'mobile' : 'pc';
|
||||
console.log("设备类型:", deviceType);
|
||||
console.log("用户角色:", userRoles);
|
||||
console.log("当前用户名:", username, `检查是否是${SPECIAL_ORDER_USER}:`, username === SPECIAL_ORDER_USER);
|
||||
console.log("用户类型:", userType, "orgType:", orgType, "设备类型:", deviceType);
|
||||
console.log("ACTION generateRoutes - auths:", auths);
|
||||
|
||||
// 修复:包含 orgType 为 2 和 3 的情况
|
||||
if (params.user && params.user.includes("admin") && orgType != 2 && orgType != 3) {
|
||||
// 管理员:只显示超级管理员菜单(仅PC端)
|
||||
if (deviceType === 'pc') {
|
||||
accessedRoutes = asyncRoutes.filter(item => item.path === '/superAdministrator');
|
||||
} else {
|
||||
accessedRoutes = [];
|
||||
}
|
||||
} else {
|
||||
const auths = params.auths ? JSON.parse(JSON.stringify(params.auths)) : [];
|
||||
console.log("ACTION generateRoutes - auths:", auths);
|
||||
// 3. 先生成第一版菜单:超级管理员只拿超管菜单,普通用户按后端 auths 过滤。
|
||||
let accessedRoutes = isSuperAdmin
|
||||
? getSuperAdminRoutes(deviceType)
|
||||
: getAccessedRoutesByPermission(auths, userRoles, deviceType);
|
||||
|
||||
if (auths.length) {
|
||||
// 确保 auths 中的 path 与路由 meta.fullPath 匹配
|
||||
const paths = auths.map((item) => {
|
||||
return item.path;
|
||||
});
|
||||
console.log("ACTION generateRoutes - paths from auths:", paths);
|
||||
// 4. token市集是公共入口,所有登录用户都补上。
|
||||
accessedRoutes = addCommonRoutes(accessedRoutes, asyncRoutes, deviceType);
|
||||
|
||||
if (paths.includes("")) {
|
||||
// 如果权限列表包含空路径,认为用户有所有权限
|
||||
accessedRoutes = asyncRoutes || [];
|
||||
} else {
|
||||
// 传入用户角色和设备类型
|
||||
accessedRoutes = filterAsyncRoutes(asyncRoutes, auths, userRoles, deviceType);
|
||||
}
|
||||
} else {
|
||||
// 如果没有权限列表,不显示任何动态路由
|
||||
accessedRoutes = [];
|
||||
}
|
||||
|
||||
// 为普通用户添加订单管理和资源管理路由以及新的五个客户菜单
|
||||
if (!isSuperAdmin) {
|
||||
// 5. 普通用户再补一些固定入口,比如订单、资源、客户专属菜单。
|
||||
console.log("为用户添加特定路由");
|
||||
const userSpecificRoutes = addUserRoutes(asyncRoutes, userType, orgType, userRoles, deviceType);
|
||||
|
||||
// 确保不重复添加路由,同时检查角色权限
|
||||
userSpecificRoutes.forEach(route => {
|
||||
const isCustomerRoute = [
|
||||
"/workOrderManagement", "/unsubscribeManagement", "/informationPerfect",
|
||||
"/rechargeManagement", "/invoiceManagement"
|
||||
].includes(route.path);
|
||||
|
||||
// 如果是客户路由但用户不是客户,则不添加
|
||||
if (isCustomerRoute && !userRoles.includes('客户')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessedRoutes.some(r => r.path === route.path)) {
|
||||
accessedRoutes.push(route);
|
||||
}
|
||||
});
|
||||
|
||||
accessedRoutes = addUserSpecificRoutes(accessedRoutes, userType, orgType, userRoles, deviceType);
|
||||
console.log("添加用户特定路由后的accessedRoutes:", accessedRoutes);
|
||||
}
|
||||
|
||||
// ========== 暴力过滤:直接修改 accessedRoutes ==========
|
||||
// 遍历所有路由,找到 /orderManagement 路由,然后过滤它的子路由
|
||||
accessedRoutes = accessedRoutes.map(route => {
|
||||
if (route.path === "/orderManagement") {
|
||||
console.log("找到订单管理路由,准备过滤子路由,用户名:", username);
|
||||
// 6. 运营角色额外补模型管理。
|
||||
accessedRoutes = addOperationRoutes(accessedRoutes, asyncRoutes, userRoles, deviceType);
|
||||
|
||||
// 创建路由副本
|
||||
const newRoute = { ...route };
|
||||
|
||||
if (newRoute.children) {
|
||||
// 如果不是 ZhipuHZ 用户,过滤掉 HistoricalOrders 和 orderDetails 路由
|
||||
if (username !== 'ZhipuHZ') {
|
||||
console.log(`用户 ${username} 不是 ZhipuHZ,过滤订单管理子路由`);
|
||||
newRoute.children = newRoute.children.filter(child =>
|
||||
child.path !== 'HistoricalOrders' && child.path !== 'orderDetails'
|
||||
);
|
||||
console.log(`过滤后子路由:`, newRoute.children.map(c => c.path));
|
||||
} else {
|
||||
console.log(`用户 ${username} 是 ZhipuHZ,保留所有子路由`);
|
||||
}
|
||||
}
|
||||
|
||||
return newRoute;
|
||||
}
|
||||
|
||||
// 对于其他路由,保持原样
|
||||
return route;
|
||||
});
|
||||
|
||||
// 再次检查,确保没有遗漏的任何 orderManagement 路由
|
||||
accessedRoutes.forEach(route => {
|
||||
if (route.children) {
|
||||
route.children = route.children.filter(child => {
|
||||
// 如果子路由是 orderManagement,也需要处理
|
||||
if (child.path === "/orderManagement") {
|
||||
console.log("在子路由中找到订单管理路由,准备过滤,用户名:", username);
|
||||
|
||||
if (child.children && username !== 'ZhipuHZ') {
|
||||
child.children = child.children.filter(grandChild =>
|
||||
grandChild.path !== 'HistoricalOrders' && grandChild.path !== 'orderDetails'
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
// 7. 最后处理订单管理里的特殊子菜单权限。
|
||||
accessedRoutes = filterOrderChildrenByUser(accessedRoutes, username);
|
||||
|
||||
console.log("ACTION generateRoutes - 最终 calculated accessedRoutes:", accessedRoutes);
|
||||
|
||||
// 8. 保存到 vuex 和 sessionStorage,侧边栏会读取 state.permission.routes。
|
||||
commit("SET_ROUTES", accessedRoutes);
|
||||
resolve(accessedRoutes);
|
||||
});
|
||||
|
||||
@ -13,6 +13,13 @@ const safeToString = (value, defaultValue = '') => {
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
const normalizeLoginRoles = (roles) => {
|
||||
if (!roles || roles === 'None') return [];
|
||||
if (Array.isArray(roles)) return roles;
|
||||
if (typeof roles === 'string') return roles.split(',').filter(Boolean);
|
||||
return [];
|
||||
};
|
||||
|
||||
// 从sessionStorage恢复状态
|
||||
const getStoredState = () => {
|
||||
return {
|
||||
@ -130,8 +137,11 @@ const actions = {
|
||||
|
||||
// 修复:org_type 为 2 或 3 都表示客户
|
||||
const userType = (org_type == 2 || org_type == 3) ? 'user' : 'admin';
|
||||
// 设置用户角色 - 如果是客户,则添加'客户'角色
|
||||
const userRoles = (org_type == 2 || org_type == 3) ? ['客户'] : ['管理员'];
|
||||
// 使用接口返回的真实角色生成菜单;客户组织兜底补上“客户”角色。
|
||||
const userRoles = normalizeLoginRoles(response.roles);
|
||||
if ((org_type == 2 || org_type == 3) && !userRoles.includes('客户')) {
|
||||
userRoles.push('客户');
|
||||
}
|
||||
|
||||
commit("SET_USER_TYPE", userType);
|
||||
// 确保 org_type 不为 undefined
|
||||
@ -141,6 +151,8 @@ const actions = {
|
||||
console.log("登录用户类型:", userType, "org_type:", org_type, "用户角色:", userRoles);
|
||||
|
||||
data ? commit("SET_AUTHS", data) : commit("SET_AUTHS", []);
|
||||
resetRouter();
|
||||
commit("permission/RESET_ROUTES", null, { root: true });
|
||||
const accessRoutes = await store.dispatch(
|
||||
"permission/generateRoutes",
|
||||
{
|
||||
@ -151,7 +163,6 @@ const actions = {
|
||||
roles: userRoles // 新增:传递角色信息
|
||||
}
|
||||
)
|
||||
resetRouter();
|
||||
router.addRoutes(accessRoutes);
|
||||
resolve(response);
|
||||
}
|
||||
@ -215,6 +226,7 @@ const actions = {
|
||||
commit("SET_AUTHS", []);
|
||||
removeToken();
|
||||
resetRouter();
|
||||
commit("permission/RESET_ROUTES", null, { root: true });
|
||||
|
||||
// 清除sessionStorage
|
||||
sessionStorage.removeItem('user');
|
||||
@ -223,6 +235,8 @@ const actions = {
|
||||
sessionStorage.removeItem('orgType');
|
||||
sessionStorage.removeItem('mybalance');
|
||||
sessionStorage.removeItem('roles'); // 新增:清除角色信息
|
||||
sessionStorage.removeItem('juese');
|
||||
sessionStorage.removeItem('jueseNew');
|
||||
|
||||
// reset visited views and cached views
|
||||
// to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485
|
||||
@ -243,6 +257,8 @@ const actions = {
|
||||
commit("SET_USER", "");
|
||||
commit("SET_AUTHS", []);
|
||||
removeToken();
|
||||
resetRouter();
|
||||
commit("permission/RESET_ROUTES", null, { root: true });
|
||||
|
||||
// 清除sessionStorage
|
||||
sessionStorage.removeItem('user');
|
||||
@ -250,6 +266,8 @@ const actions = {
|
||||
sessionStorage.removeItem('userType');
|
||||
sessionStorage.removeItem('orgType');
|
||||
sessionStorage.removeItem('roles'); // 新增:清除角色信息
|
||||
sessionStorage.removeItem('juese');
|
||||
sessionStorage.removeItem('jueseNew');
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
|
||||
// reset element-ui css
|
||||
.horizontal-collapse-transition {
|
||||
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
|
||||
transition: width .28s ease, padding-left .28s ease, padding-right .28s ease;
|
||||
}
|
||||
|
||||
.scrollbar-wrapper {
|
||||
@ -100,11 +100,11 @@
|
||||
|
||||
.hideSidebar {
|
||||
.sidebar-container {
|
||||
width: 54px !important;
|
||||
width: 64px !important;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
margin-left: 54px;
|
||||
margin-left: 64px;
|
||||
}
|
||||
|
||||
.submenu-title-noDropdown {
|
||||
|
||||
@ -20,9 +20,9 @@
|
||||
</p>
|
||||
<!-- 产品与服务:鼠标移入显示子菜单 -->
|
||||
<p @mouseleave="sildeOut" @mouseenter="sildeIn(product_service)">
|
||||
<a>产品与服务</a>
|
||||
<a>基础云</a>
|
||||
</p>
|
||||
<p class="nav-hover" @click="$router.push('/product')">模型广场</p>
|
||||
<p class="nav-hover" @click="handleModelSquareClick">token市集</p>
|
||||
<p class="nav-hover" @click="goYuanjing">元境</p>
|
||||
<!-- 供需广场 -->
|
||||
<p :class="{ active: $route.path.includes('/supply') }">
|
||||
@ -116,7 +116,11 @@
|
||||
<div class="panelLeft">
|
||||
<ul class="outUl">
|
||||
<li style="cursor: default" class="outLi" v-for="item in showPanelData" :key="item.firTitle">
|
||||
<span style="cursor: default!important;" :class="['tilte', 'activeFir']">
|
||||
<span
|
||||
:style="{ cursor: isPanelFirClickable(item) ? 'pointer' : 'default' }"
|
||||
:class="['tilte', 'activeFir']"
|
||||
@click="handlePanelFirClick(item)"
|
||||
>
|
||||
{{
|
||||
item.firTitle
|
||||
}}
|
||||
@ -255,6 +259,7 @@ import store from "@/store";
|
||||
import { getHomePath } from '@/views/setting/tools'
|
||||
import MessageCenter from '@/components/MessageCenter/MessageCenter.vue'
|
||||
import { reqAIChat } from '@/api/AI/ai'
|
||||
import { gotoYuanJingAPI } from '@/api/gotoYuanJing'
|
||||
|
||||
export default Vue.extend({
|
||||
name: "TopBox",
|
||||
@ -376,9 +381,85 @@ export default Vue.extend({
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 跳转元境 https://ai.opencomputing.cn/#/index
|
||||
goYuanjing() {
|
||||
window.open('https://ai.opencomputing.cn/#/index')
|
||||
// 点击模型广场前校验登录状态
|
||||
handleModelSquareClick() {
|
||||
if (!this.loginState) {
|
||||
this.$message.warning('请先登录哦~')
|
||||
return
|
||||
}
|
||||
this.$router.push('/product')
|
||||
},
|
||||
|
||||
// 跳转元境
|
||||
async goYuanjing() {
|
||||
if (!this.loginState) {
|
||||
this.$message.warning('请先登录哦~')
|
||||
return
|
||||
}
|
||||
|
||||
const yuanJingWindow = window.open('', '_blank')
|
||||
|
||||
try {
|
||||
const res = await gotoYuanJingAPI({
|
||||
user_id: sessionStorage.getItem('userId')
|
||||
})
|
||||
|
||||
const deerer = this.getYuanJingAuthorization(res)
|
||||
|
||||
if (!deerer) {
|
||||
if (yuanJingWindow) {
|
||||
yuanJingWindow.close()
|
||||
}
|
||||
this.$message.error((res && res.msg) || '获取元境授权参数失败')
|
||||
return
|
||||
}
|
||||
|
||||
const loginUrl = `https://ai.opencomputing.cn/#/getCookie?deerer=${encodeURIComponent(deerer)}`
|
||||
|
||||
if (yuanJingWindow) {
|
||||
yuanJingWindow.location.href = loginUrl
|
||||
} else {
|
||||
window.location.href = loginUrl
|
||||
}
|
||||
} catch (error) {
|
||||
if (yuanJingWindow) {
|
||||
yuanJingWindow.close()
|
||||
}
|
||||
this.$message.error('跳转元境失败,请稍后重试')
|
||||
}
|
||||
},
|
||||
getYuanJingAuthorization(res) {
|
||||
if (!res) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof res === 'string') {
|
||||
return res
|
||||
}
|
||||
|
||||
const data = res.data || res
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
return data.Authorization || data.authorization || data.token || data.header || data.value || ''
|
||||
},
|
||||
isPanelFirClickable(item) {
|
||||
const title = (item && item.firTitle) || ''
|
||||
return title === '元境' || title === 'TOKEN市集' || title === 'token市集'
|
||||
},
|
||||
handlePanelFirClick(item) {
|
||||
const title = (item && item.firTitle) || ''
|
||||
if (title === '元境') {
|
||||
this.$store.commit('setShowHomeNav', false)
|
||||
this.goYuanjing()
|
||||
return
|
||||
}
|
||||
|
||||
if (title === 'TOKEN市集' || title === 'token市集') {
|
||||
this.$store.commit('setShowHomeNav', false)
|
||||
this.handleModelSquareClick()
|
||||
}
|
||||
},
|
||||
// 处理AI助手点击
|
||||
handleAIClick() {
|
||||
@ -668,11 +749,17 @@ export default Vue.extend({
|
||||
async logout() {
|
||||
this.$store.commit('setLoginState', false)
|
||||
store.commit('tagsView/resetBreadcrumbState');
|
||||
store.commit('permission/RESET_ROUTES');
|
||||
sessionStorage.removeItem("auths");
|
||||
sessionStorage.removeItem("routes");
|
||||
sessionStorage.removeItem("user");
|
||||
sessionStorage.removeItem("userId");
|
||||
sessionStorage.removeItem("org_type")
|
||||
sessionStorage.removeItem("userType");
|
||||
sessionStorage.removeItem("orgType");
|
||||
sessionStorage.removeItem("roles");
|
||||
sessionStorage.removeItem("juese");
|
||||
sessionStorage.removeItem("jueseNew");
|
||||
localStorage.removeItem("auths");
|
||||
localStorage.removeItem("routes");
|
||||
localStorage.removeItem("user");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
316
f/web-kboss/src/views/login/components/ForgotPasswordDialog.vue
Normal file
316
f/web-kboss/src/views/login/components/ForgotPasswordDialog.vue
Normal file
@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
custom-class="forgot-password-dialog"
|
||||
:visible="visible"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
@open="handleOpen"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div slot="title" class="forgot-dialog-title">
|
||||
<div class="title-icon">
|
||||
<i class="el-icon-lock"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3>重置密码</h3>
|
||||
<p>通过手机号验证码验证身份后设置新密码</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="forgot-form" autocomplete="off">
|
||||
<el-form-item label="手机号" prop="username">
|
||||
<el-input v-model="form.username" clearable autocomplete="off" placeholder="请输入绑定手机号"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="password">
|
||||
<el-input v-model="form.password" clearable show-password autocomplete="new-password" placeholder="请输入新密码"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="vcode">
|
||||
<div class="code-row">
|
||||
<el-input v-model="form.vcode" clearable autocomplete="off" placeholder="请输入验证码"></el-input>
|
||||
<el-button
|
||||
class="code-btn"
|
||||
:disabled="isDisabled || isGettingCode"
|
||||
:loading="isGettingCode"
|
||||
@click="debouncedGetCode"
|
||||
>
|
||||
{{ sendCodeText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div slot="footer" class="forgot-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确认重置</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getPasswordCodeAPI, retrieveCodeAPI } from '@/api/login'
|
||||
|
||||
export default {
|
||||
name: 'ForgotPasswordDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: this.createEmptyForm(),
|
||||
rules: {
|
||||
username: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [{ required: true, message: '请输入新密码', trigger: 'blur' }],
|
||||
vcode: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
},
|
||||
sendCodeText: '获取验证码',
|
||||
isDisabled: false,
|
||||
isGettingCode: false,
|
||||
submitting: false,
|
||||
timeCount: 60,
|
||||
timer: null,
|
||||
debounceTimer: null
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.resetCodeState()
|
||||
clearTimeout(this.debounceTimer)
|
||||
},
|
||||
methods: {
|
||||
handleOpen() {
|
||||
this.resetForm()
|
||||
this.$nextTick(() => {
|
||||
this.$refs.formRef && this.$refs.formRef.clearValidate()
|
||||
})
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:visible', false)
|
||||
this.resetForm()
|
||||
},
|
||||
resetForm() {
|
||||
this.form = this.createEmptyForm()
|
||||
this.submitting = false
|
||||
this.resetCodeState()
|
||||
},
|
||||
createEmptyForm() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
vcode: '',
|
||||
codeid: ''
|
||||
}
|
||||
},
|
||||
debouncedGetCode() {
|
||||
if (this.isDisabled || this.isGettingCode) return
|
||||
|
||||
this.isGettingCode = true
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.getCode()
|
||||
}, 300)
|
||||
},
|
||||
async getCode() {
|
||||
if (!this.form.username || !/^1[3-9]\d{9}$/.test(this.form.username)) {
|
||||
this.isGettingCode = false
|
||||
this.$message.error('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await retrieveCodeAPI({
|
||||
mobile: this.form.username,
|
||||
action_type: 'forgotpassword'
|
||||
})
|
||||
|
||||
if (res.status === true) {
|
||||
this.form.codeid = res.codeid
|
||||
this.startCountdown()
|
||||
this.$message.success('验证码已发送,请注意查收。')
|
||||
return
|
||||
}
|
||||
|
||||
this.$message.error(res.msg || '验证码获取失败')
|
||||
} catch (error) {
|
||||
this.$message.error('验证码获取失败')
|
||||
} finally {
|
||||
this.isGettingCode = false
|
||||
}
|
||||
},
|
||||
startCountdown() {
|
||||
this.timeCount = 59
|
||||
this.isDisabled = true
|
||||
this.sendCodeText = `重新发送${this.timeCount}s`
|
||||
|
||||
clearInterval(this.timer)
|
||||
this.timer = setInterval(() => {
|
||||
if (this.timeCount > 0) {
|
||||
this.timeCount--
|
||||
this.sendCodeText = `重新发送${this.timeCount}s`
|
||||
return
|
||||
}
|
||||
this.resetCodeState()
|
||||
}, 1000)
|
||||
},
|
||||
resetCodeState() {
|
||||
this.sendCodeText = '获取验证码'
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
this.isDisabled = false
|
||||
this.timeCount = 60
|
||||
},
|
||||
handleSubmit() {
|
||||
this.$refs.formRef.validate(async valid => {
|
||||
if (!valid) return
|
||||
if (!this.form.codeid) {
|
||||
this.$message.error('请先获取验证码')
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
try {
|
||||
const res = await getPasswordCodeAPI({
|
||||
mobile: this.form.username,
|
||||
password: this.form.password,
|
||||
codeid: this.form.codeid,
|
||||
vcode: this.form.vcode,
|
||||
action_type: 'forgotpassword'
|
||||
})
|
||||
|
||||
if (res.status === true) {
|
||||
this.$message.success('密码重置成功')
|
||||
this.handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
this.$message.error(res.msg || '密码重置失败')
|
||||
} catch (error) {
|
||||
this.$message.error('密码重置失败')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.forgot-password-dialog {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 24px 28px 18px;
|
||||
background: linear-gradient(135deg, #f4f8ff 0%, #ffffff 70%);
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 24px 28px 8px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 14px 28px 24px;
|
||||
}
|
||||
|
||||
.el-dialog__headerbtn {
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
.title-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: #2f6bff;
|
||||
font-size: 20px;
|
||||
background: #eaf1ff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1f2d3d;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #8a94a6;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-form {
|
||||
.el-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
padding-bottom: 8px;
|
||||
color: #344054;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
height: 42px;
|
||||
border-color: #dfe5ef;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
width: 118px;
|
||||
height: 42px;
|
||||
color: #2f6bff;
|
||||
background: #eef4ff;
|
||||
border-color: #cfe0ff;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #ffffff;
|
||||
background: #2f6bff;
|
||||
border-color: #2f6bff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
.el-button {
|
||||
min-width: 90px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -110,7 +110,7 @@
|
||||
</div>
|
||||
|
||||
<div class="two-btn">
|
||||
<i></i>
|
||||
<span class="forgot-password" @click="forgotPasswordVisible = true">忘记密码?</span>
|
||||
<span :loading="loading" type="primary" class="go-register"
|
||||
style="width: 127px; margin-bottom: 30px; margin-left: 0px" @click.native.prevent="handleRegister"
|
||||
@click="handleRegister">
|
||||
@ -119,37 +119,7 @@
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 重置密码对话框暂时注释 -->
|
||||
<!--
|
||||
<el-dialog title="重置密码" :visible.sync="dialogVisible" width="25%" class="myDialog">
|
||||
<el-form ref="form" :model="form" label-width="100px" :rules="forms">
|
||||
<el-form-item label="用户名:" prop="username" class="rePassword" style="background-color: white">
|
||||
<el-input v-model="form.username" class="name-input" placeholder="请先输入用户名/手机号"
|
||||
style="background-color: white;border:1px solid #d9d9d9;border-radius: 3px;">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码:" prop="password" style="background-color: white">
|
||||
<el-input v-model="form.password" style="border:1px solid #d9d9d9;border-radius: 3px"
|
||||
placeholder="请输入新密码">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码:" prop="vcode" style="background-color: white">
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<el-input style="border:1px solid #d9d9d9;border-radius: 3px" ref="vcode" v-model="form.vcode"
|
||||
placeholder="请输入验证码" name="vcode" type="text" />
|
||||
<el-button type="primary" size="mini" style="height: 40px; margin-left: 10px"
|
||||
:disabled="isDisabled1 || isGettingCode1" @click="debouncedGetCode1">
|
||||
{{ SendCode_text1 }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="dialog-footer">
|
||||
<el-button size="small" @click="cancelReset">取 消</el-button>
|
||||
<el-button type="primary" size="small" @click="handleSubmit">确 定</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
-->
|
||||
<ForgotPasswordDialog :visible.sync="forgotPasswordVisible" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -177,12 +147,10 @@
|
||||
import {
|
||||
getCodeAPI, // 获取验证码API
|
||||
getLogoAPI, // 获取Logo信息API
|
||||
// getPasswordCodeAPI, // 重置密码API
|
||||
logintypeAPI, // 登录类型API
|
||||
loginUserAPI, // 用户登录API
|
||||
reqGetAppidAPI, // 获取微信AppID API
|
||||
reqGetCodeAPI, // 获取微信授权码API
|
||||
// retrieveCodeAPI, // 获取重置密码验证码API,
|
||||
} from "@/api/login";
|
||||
|
||||
import store from "@/store";
|
||||
@ -196,10 +164,11 @@ import { Message } from "element-ui";
|
||||
import router, { resetRouter } from "@/router";
|
||||
import { reqNewHomeFestival } from "@/api/newHome";
|
||||
import { getHomePath } from '@/views/setting/tools'
|
||||
import ForgotPasswordDialog from './components/ForgotPasswordDialog.vue'
|
||||
|
||||
export default {
|
||||
name: "indexNew",
|
||||
components: { BeforeLogin, promotionalInvitationCode },
|
||||
components: { BeforeLogin, promotionalInvitationCode, ForgotPasswordDialog },
|
||||
data() {
|
||||
return {
|
||||
// 微信登录相关
|
||||
@ -231,6 +200,7 @@ export default {
|
||||
|
||||
// 对话框和加载状态
|
||||
loading: false, // 登录按钮加载状态
|
||||
forgotPasswordVisible: false, // 忘记密码弹窗显示状态
|
||||
|
||||
// 登录表单数据
|
||||
loginForm: {
|
||||
@ -258,21 +228,6 @@ export default {
|
||||
capsTooltip: false,
|
||||
passwordType: "password", // 密码输入框类型:password或text
|
||||
|
||||
// 重置密码相关暂时注释
|
||||
// form: {
|
||||
// username: "", // 用户名
|
||||
// vcode: "", // 验证码
|
||||
// password: "", // 新密码
|
||||
// id: "", // 用户ID
|
||||
// codeid: "", // 验证码ID
|
||||
// },
|
||||
|
||||
// forms: {
|
||||
// username: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
// password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
||||
// vcode: [{ required: true, message: "请输入验证码", trigger: "blur" }],
|
||||
// },
|
||||
|
||||
// Logo显示控制
|
||||
isLogo: false,
|
||||
isShowSaleProduct: false, // 是否显示销售产品
|
||||
@ -374,20 +329,6 @@ export default {
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// 重置密码相关方法暂时注释
|
||||
// debouncedGetCode1: function () {
|
||||
// if (this.isDisabled1 || this.isGettingCode1) return;
|
||||
//
|
||||
// this.isGettingCode1 = true;
|
||||
//
|
||||
// clearTimeout(this.debounceTimer1);
|
||||
//
|
||||
// this.debounceTimer1 = setTimeout(() => {
|
||||
// this.getCode1();
|
||||
// this.isGettingCode1 = false;
|
||||
// }, 300);
|
||||
// },
|
||||
|
||||
// 跳转到百度产品页面
|
||||
goBaidu(listUrl, url) {
|
||||
this.$store.commit('setRedirectUrl', url)
|
||||
@ -464,6 +405,7 @@ export default {
|
||||
sessionStorage.setItem("username", user.username); // 存储用户名
|
||||
|
||||
// 处理用户角色
|
||||
const routeRoles = admin !== 1 ? response.roles : ['admin'];
|
||||
if (admin !== 1) {
|
||||
sessionStorage.setItem("juese", response.roles[0]);
|
||||
sessionStorage.setItem("jueseNew", response.roles);
|
||||
@ -474,14 +416,18 @@ export default {
|
||||
sessionStorage.setItem("juese", "admin");
|
||||
sessionStorage.setItem("jueseNew", "admin");
|
||||
}
|
||||
sessionStorage.setItem("roles", JSON.stringify(routeRoles));
|
||||
|
||||
// 生成路由并跳转
|
||||
resetRouter();
|
||||
this.$store.commit("permission/RESET_ROUTES");
|
||||
const accessRoutes = await this.$store.dispatch("permission/generateRoutes", {
|
||||
user: user.username,
|
||||
auths: data,
|
||||
admin: admin || "",
|
||||
orgType: org_type,
|
||||
roles: routeRoles,
|
||||
});
|
||||
resetRouter();
|
||||
console.log("生成的路径是", accessRoutes);
|
||||
router.addRoutes(accessRoutes);
|
||||
|
||||
@ -742,54 +688,6 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// getCode1() {
|
||||
// if (!this.form.username || !/^1[3-9]\d{9}$/.test(this.form.username)) {
|
||||
// this.$message.error('请输入正确的手机号');
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// retrieveCodeAPI({
|
||||
// mobile: this.form.username,
|
||||
// action_type: 'login'
|
||||
// }).then((res) => {
|
||||
// if (res.status == true) {
|
||||
// this.form.id = res.userid;
|
||||
// this.form.codeid = res.codeid;
|
||||
// let that = this;
|
||||
// this.time_count1 = 59;
|
||||
// this.isDisabled1 = true;
|
||||
// this.SendCode_text1 = "重新发送" + this.time_count1 + "s";
|
||||
//
|
||||
// if (!that.timer1) {
|
||||
// that.timer1 = setInterval(() => {
|
||||
// if (that.time_count1 > 0) {
|
||||
// that.time_count1--;
|
||||
// that.SendCode_text1 = "重新发送" + that.time_count1 + "s";
|
||||
// } else {
|
||||
// that.SendCode_text1 = "获取验证码";
|
||||
// clearInterval(that.timer1);
|
||||
// that.timer1 = null;
|
||||
// this.isDisabled1 = false;
|
||||
// that.time_count1 = 60;
|
||||
// }
|
||||
// }, 1000);
|
||||
// }
|
||||
// this.$message({
|
||||
// message: "验证码已发送,请注意查收。",
|
||||
// type: "success",
|
||||
// });
|
||||
// } else {
|
||||
// this.$message({
|
||||
// message: res.msg,
|
||||
// type: "error",
|
||||
// });
|
||||
// }
|
||||
// }).catch(error => {
|
||||
// this.isGettingCode1 = false;
|
||||
// this.$message.error('验证码获取失败');
|
||||
// });
|
||||
// },
|
||||
|
||||
// 标签页点击事件
|
||||
handleClick(tab, event) {
|
||||
console.log(tab, event);
|
||||
@ -927,50 +825,12 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// resetPassword() {
|
||||
// this.dialogVisible = true;
|
||||
// this.$refs.loginForm.resetFields();
|
||||
// },
|
||||
|
||||
// 跳转到注册页面
|
||||
handleRegister() {
|
||||
console.log("注册按钮被点击了")
|
||||
this.$router.push({ name: "registrationPage" });
|
||||
},
|
||||
|
||||
// cancelReset() {
|
||||
// this.dialogVisible = false;
|
||||
// this.$refs.form.resetFields();
|
||||
// },
|
||||
|
||||
// handleSubmit() {
|
||||
// let parmas = {
|
||||
// id: this.form.id,
|
||||
// password: this.form.password,
|
||||
// codeid: this.form.codeid,
|
||||
// vcode: this.form.vcode,
|
||||
// };
|
||||
// getPasswordCodeAPI(parmas).then((res) => {
|
||||
// if (res.status == true) {
|
||||
// this.$message({
|
||||
// message: "密码重置成功",
|
||||
// type: "success",
|
||||
// });
|
||||
// this.isDisabled1 = false;
|
||||
// this.dialogVisible = false;
|
||||
// this.SendCode_text1 = "获取验证码";
|
||||
// clearInterval(this.timer1);
|
||||
// this.timer1 = null;
|
||||
// this.time_count1 = 60;
|
||||
// this.$refs.form.resetFields();
|
||||
// } else {
|
||||
// this.$message({
|
||||
// message: res.msg,
|
||||
// type: "error",
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -1158,10 +1018,22 @@ $dark_gray: #889aa4;
|
||||
.two-btn {
|
||||
width: 300px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
margin-bottom: 30px;
|
||||
color: #409eff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.go-register {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
|
||||
422
f/web-kboss/src/views/modelManagement/AddModelDialog.vue
Normal file
422
f/web-kboss/src/views/modelManagement/AddModelDialog.vue
Normal file
@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
title="模型上架"
|
||||
:visible.sync="dialogVisible"
|
||||
width="760px"
|
||||
custom-class="add-model-dialog"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<el-form ref="form" class="model-form" :model="form" :rules="rules" label-position="top">
|
||||
<section class="form-section">
|
||||
<div class="section-title">
|
||||
<i class="el-icon-info"></i>
|
||||
<span>基本信息</span>
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模型名称/版本" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入模型名称/版本"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模型类型" prop="type">
|
||||
<el-input v-model="form.type" placeholder="请输入模型类型"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="供应商" prop="supplier">
|
||||
<el-input v-model="form.supplier" placeholder="请输入供应商"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="section-title">
|
||||
<i class="el-icon-money"></i>
|
||||
<span>模型价格</span>
|
||||
</div>
|
||||
<el-form-item label="计费方式">
|
||||
<el-input v-model="form.billingMethod" placeholder="请输入计费方式"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-input v-model="form.unit" placeholder="请输入单位"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="输入价格">
|
||||
<el-input v-model="form.inputPrice" placeholder="请输入输入价格"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="输出价格">
|
||||
<el-input v-model="form.outputPrice" placeholder="请输入输出价格"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="缓存命中价格">
|
||||
<el-input v-model="form.cacheHitInputPrice" placeholder="请输入缓存命中价格"></el-input>
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="section-title">
|
||||
<i class="el-icon-s-operation"></i>
|
||||
<span>模型介绍</span>
|
||||
</div>
|
||||
<el-form-item prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请详细描述模型的功能、特点、使用场景等信息"
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
</section>
|
||||
</el-form>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button class="cancel-btn" @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" class="submit-btn" icon="el-icon-upload2" @click="handleSubmit">提交</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const defaultForm = () => ({
|
||||
id: '',
|
||||
llmid: '',
|
||||
name: '',
|
||||
displayName: '',
|
||||
type: '',
|
||||
supplier: '',
|
||||
contextLength: '',
|
||||
billingMethod: '',
|
||||
unit: '',
|
||||
inputPrice: '',
|
||||
outputPrice: '',
|
||||
cacheHitInputPrice: '',
|
||||
capabilities: '',
|
||||
limitations: '',
|
||||
highlights: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
export default {
|
||||
name: 'AddModelDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
modelDetail: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: defaultForm(),
|
||||
modelNameOptions: ['GPT-3.5-Turbo', 'GPT-4', 'Claude-3', 'DeepSeek-V4', 'Llama-2-70B'],
|
||||
modelTypeOptions: [
|
||||
{ label: '自然语言处理', value: '自然语言处理' },
|
||||
{ label: '计算机视觉', value: '计算机视觉' },
|
||||
{ label: '语音', value: '语音' },
|
||||
{ label: '多模态', value: '多模态' }
|
||||
],
|
||||
supplierOptions: ['OpenAI', 'Google', '开元云', 'Anthropic', 'Meta', 'DeepSeek'],
|
||||
rules: {
|
||||
name: [{ required: true, message: '请输入模型名称/版本', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请输入模型类型', trigger: 'blur' }],
|
||||
supplier: [{ required: true, message: '请输入供应商', trigger: 'blur' }],
|
||||
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }],
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dialogVisible: {
|
||||
get() {
|
||||
return this.visible
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:visible', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(value) {
|
||||
if (value) {
|
||||
this.fillForm(this.modelDetail)
|
||||
}
|
||||
},
|
||||
modelDetail: {
|
||||
handler(value) {
|
||||
if (this.visible) {
|
||||
this.fillForm(value)
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fillForm(detail) {
|
||||
if (!detail) {
|
||||
this.form = defaultForm()
|
||||
return
|
||||
}
|
||||
|
||||
this.form = {
|
||||
id: detail.id || '',
|
||||
llmid: detail.llmid || '',
|
||||
name: detail.model_name || '',
|
||||
displayName: detail.display_name || detail.model_name || '',
|
||||
type: detail.model_type || '',
|
||||
supplier: detail.provider || '',
|
||||
contextLength: detail.context_length || '',
|
||||
billingMethod: detail.billing_method || '',
|
||||
unit: detail.billing_unit || '',
|
||||
inputPrice: detail.input_token_price == null ? '' : String(detail.input_token_price),
|
||||
outputPrice: detail.output_token_price == null ? '' : String(detail.output_token_price),
|
||||
cacheHitInputPrice: detail.cache_hit_input_price == null ? '' : String(detail.cache_hit_input_price),
|
||||
capabilities: detail.capabilities || '',
|
||||
limitations: detail.limitations || '',
|
||||
highlights: detail.highlights || '',
|
||||
description: detail.description || ''
|
||||
}
|
||||
},
|
||||
buildSubmitPayload() {
|
||||
return {
|
||||
id: this.form.id,
|
||||
llmid: this.form.llmid,
|
||||
provider: this.form.supplier,
|
||||
model_name: this.form.name,
|
||||
display_name: this.form.name,
|
||||
context_length: this.form.contextLength,
|
||||
model_type: this.form.type,
|
||||
input_token_price: this.form.inputPrice,
|
||||
output_token_price: this.form.outputPrice,
|
||||
cache_hit_input_price: this.form.cacheHitInputPrice,
|
||||
billing_method: this.form.billingMethod,
|
||||
billing_unit: this.form.unit,
|
||||
capabilities: this.form.capabilities,
|
||||
limitations: this.form.limitations,
|
||||
highlights: this.form.highlights,
|
||||
description: this.form.description
|
||||
}
|
||||
},
|
||||
handleFileChange(file, fileList) {
|
||||
this.form.fileList = fileList
|
||||
},
|
||||
handleFileRemove(file, fileList) {
|
||||
this.form.fileList = fileList
|
||||
},
|
||||
handleClose() {
|
||||
this.dialogVisible = false
|
||||
},
|
||||
handleSubmit() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('submit', this.buildSubmitPayload())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/deep/ .add-model-dialog {
|
||||
margin-top: 40px !important;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.el-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-dialog__headerbtn {
|
||||
top: 21px;
|
||||
right: 24px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 0 24px;
|
||||
background: #ffffff;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 16px 24px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #edf0f3;
|
||||
}
|
||||
}
|
||||
|
||||
.model-form {
|
||||
max-height: calc(100vh - 300px);
|
||||
padding: 24px 26px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #8a8f98;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
}
|
||||
|
||||
/deep/ .el-row {
|
||||
max-width: 100%;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/deep/ .el-col {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
/deep/ .el-col:nth-child(2n) {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 28px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
|
||||
i {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 12px;
|
||||
font-size: 14px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section:nth-child(1) .section-title i {
|
||||
color: #8b3ff6;
|
||||
background: #f2e8ff;
|
||||
}
|
||||
|
||||
.form-section:nth-child(2) .section-title i {
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.form-section:nth-child(3) .section-title i {
|
||||
color: #6366f1;
|
||||
background: #e8e9ff;
|
||||
}
|
||||
|
||||
/deep/ .el-form-item {
|
||||
margin-bottom: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/deep/ .el-form-item__label {
|
||||
padding: 0 0 7px;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/deep/ .el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label::before {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
/deep/ .el-input__inner,
|
||||
/deep/ .el-textarea__inner {
|
||||
max-width: 100%;
|
||||
color: #111827;
|
||||
border-color: #dcdfe6;
|
||||
border-radius: 7px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/deep/ .el-select,
|
||||
/deep/ .el-input,
|
||||
/deep/ .el-textarea {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/deep/ .el-input__inner {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
/deep/ .el-input__inner::placeholder,
|
||||
/deep/ .el-textarea__inner::placeholder {
|
||||
color: #a8abb2;
|
||||
}
|
||||
|
||||
/deep/ .el-textarea__inner {
|
||||
min-height: 112px !important;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
color: #374151;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #111827;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
min-width: 104px;
|
||||
height: 40px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #8b2ff6 0%, #b02cf4 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 18px rgba(139, 47, 246, 0.28);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: linear-gradient(135deg, #7c25e8 0%, #a025e6 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
302
f/web-kboss/src/views/modelManagement/ApiDocument.vue
Normal file
302
f/web-kboss/src/views/modelManagement/ApiDocument.vue
Normal file
@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="api-doc-page">
|
||||
<header class="doc-nav">
|
||||
<button type="button" @click="goBack">
|
||||
<i class="el-icon-arrow-left"></i>
|
||||
返回
|
||||
</button>
|
||||
<span>API文档</span>
|
||||
</header>
|
||||
|
||||
<main class="doc-container">
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
class="doc-alert"
|
||||
:title="errorMessage"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
></el-alert>
|
||||
|
||||
<section class="doc-hero">
|
||||
<h1>{{ apiDoc.model_name }} API 文档</h1>
|
||||
<p>{{ heroDescription }}</p>
|
||||
<!-- <div class="quick-tabs">
|
||||
<span v-for="item in quickTabs" :key="item">{{ item }}</span>
|
||||
</div> -->
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="doc-section">
|
||||
<h2>1. 接口地址</h2>
|
||||
<p>统一使用 HTTPS 请求,所有接口都需要携带平台签发的 API Key。</p>
|
||||
<pre><code>{{ apiUrlText }}</code></pre>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<section class="doc-section">
|
||||
<h2>2. 请求示例</h2>
|
||||
<pre><code>{{ apiDoc.curl_code || requestExample }}</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="doc-section">
|
||||
<h2>3. Python 示例</h2>
|
||||
<pre><code>{{ apiDoc.python_code || pythonExample }}</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="doc-section">
|
||||
<h2>4. 错误码</h2>
|
||||
<el-table :data="errorCodes" border size="small">
|
||||
<el-table-column prop="code" label="错误码" width="140"></el-table-column>
|
||||
<el-table-column prop="message" label="说明"></el-table-column>
|
||||
<el-table-column prop="suggestion" label="处理建议"></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="doc-footer">© 2026 开元云科技 · API 文档中心</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { reqModelApiDocument } from '@/api/model/model'
|
||||
|
||||
export default {
|
||||
name: 'ApiDocument',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
apiDoc: {
|
||||
id: '',
|
||||
api_url: '',
|
||||
model_id: '',
|
||||
curl_code: '',
|
||||
python_code: '',
|
||||
model_name: '模型'
|
||||
},
|
||||
quickTabs: ['API概览', '认证方式', '请求参数', '代码示例', '错误码'],
|
||||
capabilityTable: [
|
||||
{ name: '文本对话', value: '支持单轮和多轮对话生成', status: '支持' },
|
||||
{ name: '流式输出', value: '通过 stream=true 开启 SSE 增量返回', status: '支持' },
|
||||
{ name: 'Function Call', value: '支持工具调用和结构化参数', status: '支持' },
|
||||
{ name: '图像输入', value: '可在 messages 中传入图片内容', status: '支持' },
|
||||
{ name: '私有化部署', value: '当前公共服务暂不支持私有化', status: '暂不支持' }
|
||||
],
|
||||
requestParams: [
|
||||
{ name: 'model', type: 'string', required: '是', desc: '模型 ID,例如 minimax-m2.5' },
|
||||
{ name: 'messages', type: 'array', required: '是', desc: '对话消息列表,包含 role 和 content' },
|
||||
{ name: 'temperature', type: 'number', required: '否', desc: '采样温度,数值越高输出越随机' },
|
||||
{ name: 'stream', type: 'boolean', required: '否', desc: '是否开启流式返回' },
|
||||
{ name: 'max_tokens', type: 'number', required: '否', desc: '限制模型最大输出长度' }
|
||||
],
|
||||
errorCodes: [
|
||||
{ code: '401', message: '认证失败', suggestion: '检查 API Key 是否正确或过期' },
|
||||
{ code: '429', message: '请求过于频繁', suggestion: '降低并发或等待限流恢复' },
|
||||
{ code: '500', message: '服务异常', suggestion: '稍后重试或联系平台支持' }
|
||||
],
|
||||
requestExample: `curl https://api.kboss.example.com/v2/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer $KBOSS_API_KEY" \\
|
||||
-d '{
|
||||
"model": "minimax-m2.5",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "帮我写一段模型上架介绍"
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"stream": false
|
||||
}'`,
|
||||
pythonExample: `import requests
|
||||
|
||||
url = "https://api.kboss.example.com/v2/chat/completions"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer $KBOSS_API_KEY"
|
||||
}
|
||||
data = {
|
||||
"model": "minimax-m2.5",
|
||||
"messages": [{"role": "user", "content": "Hello!"}],
|
||||
"stream": False
|
||||
}
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
print(response.json())`
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
apiUrlText() {
|
||||
const curlUrl = this.getUrlFromCurl(this.apiDoc.curl_code)
|
||||
const url = this.apiDoc.api_url || curlUrl
|
||||
return url ? `POST ${url}` : '接口地址以后端返回为准'
|
||||
},
|
||||
heroDescription() {
|
||||
const modelName = this.apiDoc.model_name || '当前模型'
|
||||
return `${modelName} 接口调用示例,包含 curl 与 Python 两种接入方式。`
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchApiDocument()
|
||||
},
|
||||
watch: {
|
||||
'$route.query.id'() {
|
||||
this.fetchApiDocument()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 通过路由传入的 id 获取模型 API 文档数据。
|
||||
async fetchApiDocument() {
|
||||
const id = this.$route.query.id || this.$route.query.model_id
|
||||
if (!id) {
|
||||
this.errorMessage = '缺少模型ID,无法获取 API 文档'
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.errorMessage = ''
|
||||
try {
|
||||
const res = await reqModelApiDocument({ id })
|
||||
if (res && res.status && res.data) {
|
||||
this.apiDoc = {
|
||||
...this.apiDoc,
|
||||
...res.data,
|
||||
model_name: res.data.model_name || this.apiDoc.model_name
|
||||
}
|
||||
return
|
||||
}
|
||||
this.errorMessage = (res && res.msg) || 'API 文档数据获取失败'
|
||||
} catch (error) {
|
||||
console.error('[API文档] 获取模型 API 文档失败', error)
|
||||
this.errorMessage = 'API 文档数据获取失败,请稍后重试'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
getUrlFromCurl(curlCode) {
|
||||
if (!curlCode) return ''
|
||||
const match = String(curlCode).match(/https?:\/\/[^\s\\]+/)
|
||||
return match ? match[0] : ''
|
||||
},
|
||||
goBack() {
|
||||
if (this.$route.query.from === 'tokenMarket') {
|
||||
this.$router.push({ path: '/product', query: { category: 'TOKEN市集' } })
|
||||
return
|
||||
}
|
||||
this.$router.back()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.api-doc-page {
|
||||
height: 100vh;
|
||||
color: #1f2d3d;
|
||||
background: #f6f8fb;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.doc-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 28px;
|
||||
color: #667085;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
margin-right: 16px;
|
||||
color: #667085;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-container {
|
||||
width: 920px;
|
||||
margin: 28px auto 0;
|
||||
}
|
||||
|
||||
.doc-alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.doc-hero,
|
||||
.doc-section {
|
||||
padding: 24px;
|
||||
margin-bottom: 18px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.doc-hero {
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 18px;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
span {
|
||||
padding: 6px 12px;
|
||||
color: #2f6bff;
|
||||
background: #eef4ff;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-section {
|
||||
h2 {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 10px;
|
||||
color: #1f2d3d;
|
||||
font-size: 18px;
|
||||
border-left: 3px solid #2f6bff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #667085;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
color: #e6edf3;
|
||||
overflow-x: auto;
|
||||
background: #1f2329;
|
||||
border-radius: 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.doc-footer {
|
||||
padding: 32px 0;
|
||||
color: #98a2b3;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
1152
f/web-kboss/src/views/modelManagement/Experience.vue
Normal file
1152
f/web-kboss/src/views/modelManagement/Experience.vue
Normal file
File diff suppressed because it is too large
Load Diff
522
f/web-kboss/src/views/modelManagement/ModelDetail.vue
Normal file
522
f/web-kboss/src/views/modelManagement/ModelDetail.vue
Normal file
@ -0,0 +1,522 @@
|
||||
<template>
|
||||
<div class="model-detail-page">
|
||||
<header class="top-nav">
|
||||
<button class="back-btn" type="button" @click="goBack">
|
||||
<i class="el-icon-arrow-left"></i>
|
||||
返回
|
||||
</button>
|
||||
<span class="nav-divider"></span>
|
||||
<span class="token-market-link" @click="goTokenMarket">Token市集</span>
|
||||
<div class="nav-actions">
|
||||
<span>控制台</span>
|
||||
<span>用户后台</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="detail-container">
|
||||
<section class="model-hero-card">
|
||||
<div class="model-logo">
|
||||
<i class="el-icon-data-analysis"></i>
|
||||
</div>
|
||||
<div class="model-summary">
|
||||
<div class="model-title-row">
|
||||
<h1>{{ modelInfo.name }}</h1>
|
||||
<el-tag size="mini" type="success">{{ modelInfo.tag }}</el-tag>
|
||||
</div>
|
||||
<div class="model-meta">
|
||||
<span v-for="item in modelInfo.metaList" :key="item">{{ item }}</span>
|
||||
</div>
|
||||
<p>{{ modelInfo.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="version-card">
|
||||
<div>
|
||||
<h3>版本介绍</h3>
|
||||
<p>
|
||||
模型ID:{{ modelInfo.modelId }}
|
||||
<i class="el-icon-copy-document"></i>
|
||||
</p>
|
||||
<p>{{ modelInfo.versionDescription }}</p>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<el-button size="small" @click="goApiDocument">API文档</el-button>
|
||||
<el-button size="small" type="primary" @click="goExperience">体验</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="two-column">
|
||||
<div class="info-card">
|
||||
<h3>模型能力</h3>
|
||||
<div v-for="item in capabilityList" :key="item.label" class="info-row">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>模型限制</h3>
|
||||
<div v-for="item in limitList" :key="item.label" class="info-row">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="price-card">
|
||||
<h3>服务价格</h3>
|
||||
<div class="price-list">
|
||||
<div class="price-item input">
|
||||
<span>模型输入</span>
|
||||
<strong>{{ modelInfo.inputPrice }}</strong>
|
||||
<em>{{ modelInfo.priceUnit }}</em>
|
||||
</div>
|
||||
<div class="price-item output">
|
||||
<span>模型输出</span>
|
||||
<strong>{{ modelInfo.outputPrice }}</strong>
|
||||
<em>{{ modelInfo.priceUnit }}</em>
|
||||
</div>
|
||||
<div v-if="hasValue(modelInfo.cacheHitInputPrice)" class="price-item cache">
|
||||
<span>缓存命中输入</span>
|
||||
<strong>{{ modelInfo.cacheHitInputPrice }}</strong>
|
||||
<em>{{ modelInfo.priceUnit }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="article-card">
|
||||
<h3>1. 模型介绍</h3>
|
||||
<p>{{ modelInfo.longDescription }}</p>
|
||||
|
||||
<h3>2. 模型亮点</h3>
|
||||
<div
|
||||
v-for="(item, index) in featureList"
|
||||
:key="item.label"
|
||||
class="feature-block"
|
||||
:class="index % 2 === 0 ? 'blue' : 'purple'"
|
||||
>
|
||||
<i :class="index % 2 === 0 ? 'el-icon-cpu' : 'el-icon-magic-stick'"></i>
|
||||
<div>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<p>{{ item.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="page-footer">© 2026 开元云科技 · 模型公共服务平台</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ModelDetail',
|
||||
data() {
|
||||
return {
|
||||
modelInfo: {
|
||||
name: 'MiniMax-M2.5',
|
||||
modelId: 'abab7c72c278cfba',
|
||||
description: 'MiniMax-M2.5 是面向复杂任务处理的通用语言模型,适用于知识问答、文案创作、工具调用和办公生产力场景。',
|
||||
longDescription: 'MiniMax-M2.5 是 MiniMax 推出的新一代旗舰语言模型,致力于提升真实世界复杂任务中的表现。在推理、工具使用和搜索、办公生产力场景中均具备较好的任务完成能力。',
|
||||
tag: '对话模型',
|
||||
metaList: ['MiniMax', '对话模型', '192K上下文'],
|
||||
versionDescription: '当前版本能力稳定,适合内容生成、知识问答、工具调用和复杂任务规划。',
|
||||
inputPrice: '0.0021',
|
||||
outputPrice: '0.0084',
|
||||
cacheHitInputPrice: '',
|
||||
priceUnit: '元/千Tokens'
|
||||
},
|
||||
capabilityList: [
|
||||
{ label: '接口类型', value: '/v2/chat/completions' },
|
||||
{ label: '接入ID', value: 'minimax-m2.5' },
|
||||
{ label: '输入类型', value: '文本 / 图像 / 音频' },
|
||||
{ label: '输出类型', value: '文本 / 结构化输出' },
|
||||
{ label: 'Function Call', value: '支持' },
|
||||
{ label: '联网搜索', value: '支持' },
|
||||
{ label: '私有化部署', value: '不支持' }
|
||||
],
|
||||
limitList: [
|
||||
{ label: '输入长度', value: '192K' },
|
||||
{ label: '单轮输出', value: '32K' },
|
||||
{ label: '输出长度', value: '128K' },
|
||||
{ label: '服务速度限制', value: '60 RPM / 250000 TPM' }
|
||||
],
|
||||
featureList: [
|
||||
{ label: '推理', value: '擅长处理数学、代码、逻辑分析和复杂任务拆解,适合作为业务助手和智能问答底座。' },
|
||||
{ label: '模型调优', value: '提供稳定的上下文理解能力,便于后续结合业务数据进行知识增强和场景优化。' }
|
||||
]
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadModelDetail()
|
||||
},
|
||||
watch: {
|
||||
'$route.query.id'() {
|
||||
this.loadModelDetail()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// TOKEN 市集卡片跳转时会缓存完整模型对象,详情页优先使用这份数据渲染。
|
||||
loadModelDetail() {
|
||||
const model = this.getCachedTokenMarketModel()
|
||||
if (!model) return
|
||||
|
||||
const name = this.displayValue(model.display_name || model.model_name)
|
||||
const modelId = this.displayValue(model.llmid || model.model_name || model.id)
|
||||
const modelType = this.displayValue(model.model_type)
|
||||
const provider = this.displayValue(model.provider)
|
||||
const contextLength = this.displayValue(model.context_length)
|
||||
const billingMethod = this.displayValue(model.billing_method)
|
||||
const description = this.normalizeText(model.description)
|
||||
|
||||
this.modelInfo = {
|
||||
name,
|
||||
modelId,
|
||||
description,
|
||||
longDescription: description,
|
||||
tag: modelType,
|
||||
metaList: [provider, modelType, contextLength !== '-' ? `${contextLength}上下文` : '', billingMethod].filter(Boolean),
|
||||
versionDescription: `${name} 当前版本由 ${provider} 提供,适合在 ${modelType} 场景中使用。`,
|
||||
inputPrice: this.formatPrice(model.input_token_price),
|
||||
outputPrice: this.formatPrice(model.output_token_price),
|
||||
cacheHitInputPrice: this.formatPrice(model.cache_hit_input_price),
|
||||
priceUnit: this.getPriceUnit(model.billing_unit)
|
||||
}
|
||||
this.capabilityList = this.parseInfoList(model.capabilities, [
|
||||
{ label: '接口类型', value: '-' },
|
||||
{ label: '接入ID', value: modelId },
|
||||
{ label: '模型类型', value: modelType },
|
||||
{ label: '供应商', value: provider }
|
||||
])
|
||||
this.limitList = this.parseInfoList(model.limitations, [
|
||||
{ label: '上下文长度', value: contextLength },
|
||||
{ label: '计费方式', value: billingMethod },
|
||||
{ label: '计费单位', value: this.displayValue(model.billing_unit) }
|
||||
])
|
||||
this.featureList = this.parseInfoList(model.highlights, [
|
||||
{ label: '模型说明', value: description },
|
||||
{ label: '适用场景', value: `${modelType}、内容生成、智能问答` }
|
||||
])
|
||||
},
|
||||
getCachedTokenMarketModel() {
|
||||
const cache = sessionStorage.getItem('tokenMarketSelectedModel')
|
||||
if (!cache) return null
|
||||
|
||||
try {
|
||||
const model = JSON.parse(cache)
|
||||
const queryId = String(this.$route.query.id || this.$route.query.model_id || '')
|
||||
if (!queryId || String(model.id) === queryId || String(model.llmid) === queryId || String(model.model_name) === queryId) {
|
||||
return model
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[模型详情] 解析 TOKEN 市集模型缓存失败', error)
|
||||
}
|
||||
return null
|
||||
},
|
||||
parseInfoList(value, fallback = []) {
|
||||
const parsed = this.safeParse(value)
|
||||
let list = []
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
list = parsed.map((item, index) => ({
|
||||
label: this.displayValue(item.name || item.label || item.key || `字段${index + 1}`),
|
||||
value: this.displayValue(this.pickValue(item.value, item.text, item.content))
|
||||
}))
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
list = Object.keys(parsed).map(key => ({
|
||||
label: key,
|
||||
value: this.displayValue(parsed[key])
|
||||
}))
|
||||
} else if (typeof parsed === 'string' && parsed) {
|
||||
list = [{ label: '说明', value: parsed }]
|
||||
}
|
||||
|
||||
return list.length ? list : fallback
|
||||
},
|
||||
normalizeText(value) {
|
||||
const parsed = this.safeParse(value)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(item => `${this.displayValue(item.name || item.label)}:${this.displayValue(item.value)}`).join(';')
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return Object.keys(parsed).map(key => `${key}:${this.displayValue(parsed[key])}`).join(';')
|
||||
}
|
||||
return this.displayValue(parsed)
|
||||
},
|
||||
safeParse(value) {
|
||||
if (value === undefined || value === null || value === '') return ''
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (error) {
|
||||
return value
|
||||
}
|
||||
},
|
||||
pickValue(...values) {
|
||||
return values.find(value => value !== undefined && value !== null && value !== '')
|
||||
},
|
||||
formatPrice(value) {
|
||||
if (!this.hasValue(value)) return '-'
|
||||
const num = Number(value)
|
||||
if (Number.isNaN(num)) return value
|
||||
return num.toFixed(6).replace(/\.?0+$/, '')
|
||||
},
|
||||
getPriceUnit(unit) {
|
||||
return unit ? `元/${unit}` : '元/千Tokens'
|
||||
},
|
||||
displayValue(value) {
|
||||
if (value === undefined || value === null || value === '') return '-'
|
||||
return value
|
||||
},
|
||||
hasValue(value) {
|
||||
return value !== undefined && value !== null && value !== '' && value !== '-'
|
||||
},
|
||||
goTokenMarket() {
|
||||
this.$router.push({ path: '/product', query: { category: 'TOKEN市集' } })
|
||||
},
|
||||
goBack() {
|
||||
if (this.$route.query.from === 'tokenMarket') {
|
||||
this.goTokenMarket()
|
||||
return
|
||||
}
|
||||
this.$router.back()
|
||||
},
|
||||
buildModelQuery() {
|
||||
return {
|
||||
id: this.$route.query.id,
|
||||
model_id: this.$route.query.model_id || this.$route.query.id,
|
||||
from: 'tokenMarket',
|
||||
category: 'TOKEN市集'
|
||||
}
|
||||
},
|
||||
goApiDocument() {
|
||||
this.$router.push({ name: 'modelApiDocument', query: this.buildModelQuery() })
|
||||
},
|
||||
goExperience() {
|
||||
this.$router.push({ name: 'modelExperience', query: this.buildModelQuery() })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-detail-page {
|
||||
height: 100vh;
|
||||
color: #1f2d3d;
|
||||
background: #f6f8fb;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 28px;
|
||||
color: #667085;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 0;
|
||||
color: #667085;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
margin: 0 14px;
|
||||
background: #d8dee9;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.token-market-link {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #2f6bff;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
width: 920px;
|
||||
margin: 28px auto 0;
|
||||
}
|
||||
|
||||
.model-hero-card,
|
||||
.version-card,
|
||||
.info-card,
|
||||
.price-card,
|
||||
.article-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.model-hero-card {
|
||||
display: flex;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.model-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 58px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin-right: 18px;
|
||||
color: #ffffff;
|
||||
font-size: 28px;
|
||||
background: linear-gradient(135deg, #ff6b6b, #7c6cff);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.model-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.model-summary p,
|
||||
.version-card p,
|
||||
.article-card p {
|
||||
color: #667085;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.version-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 22px 28px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 24px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 18px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #2f6bff;
|
||||
}
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
|
||||
span {
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.price-card,
|
||||
.article-card {
|
||||
padding: 24px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.price-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.price-item {
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
|
||||
span,
|
||||
em {
|
||||
display: block;
|
||||
color: #667085;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 8px 0 4px;
|
||||
color: #5356d8;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
&.input {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
&.output {
|
||||
background: #fff0fa;
|
||||
}
|
||||
|
||||
&.cache {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-block {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
margin-top: 14px;
|
||||
border-radius: 12px;
|
||||
|
||||
i {
|
||||
color: #2f6bff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&.blue {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
&.purple {
|
||||
background: #fff0fa;
|
||||
}
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
padding: 32px 0;
|
||||
color: #98a2b3;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
580
f/web-kboss/src/views/modelManagement/modelManagement.vue
Normal file
580
f/web-kboss/src/views/modelManagement/modelManagement.vue
Normal file
@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<div class="model-page">
|
||||
<!-- 筛选区 -->
|
||||
<model-filter
|
||||
:search-form="searchForm"
|
||||
:model-type-options="modelTypeOptions"
|
||||
:provider-options="providerOptions"
|
||||
@search="handleSearch"
|
||||
@reset="resetSearch"
|
||||
/>
|
||||
|
||||
<!-- 统计区 -->
|
||||
<model-stats :stats="modelStats" />
|
||||
|
||||
<!-- 列表区 -->
|
||||
<el-card class="model-table-card" shadow="never">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h3>模型列表</h3>
|
||||
<p>展示模型基础信息,支持上下架、排序、编辑等操作。</p>
|
||||
</div>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="fetchModelList">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeStatus" class="model-status-tabs" @tab-click="handleTabChange">
|
||||
<el-tab-pane label="待上架" name="pending" />
|
||||
<el-tab-pane label="已上架" name="listed" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 统一表格,列根据页签动态显示 -->
|
||||
<el-table
|
||||
v-loading="tableLoading"
|
||||
:data="pagedModelList"
|
||||
class="model-table"
|
||||
style="width: 100%"
|
||||
>
|
||||
<!-- 模型ID -->
|
||||
<el-table-column label="模型ID" min-width="100" align="center">
|
||||
<template slot-scope="scope">{{ getModelId(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 模型名称/版本 -->
|
||||
<el-table-column label="模型名称/版本" min-width="180">
|
||||
<template slot-scope="scope">
|
||||
<span class="model-name-text">{{ getModelDisplayName(scope.row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 模型类型 -->
|
||||
<el-table-column label="模型类型" min-width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="getModelType(scope.row) !== '-'" size="mini" class="model-tag">
|
||||
{{ getModelType(scope.row) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 供应商 -->
|
||||
<el-table-column label="供应商" min-width="120" align="center">
|
||||
<template slot-scope="scope">{{ getProvider(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 仅已上架:展示价格 -->
|
||||
<el-table-column v-if="activeStatus === 'listed'" label="展示价格" min-width="190">
|
||||
<template slot-scope="scope">
|
||||
<div class="price-cell">
|
||||
<p>输入价格: {{ formatPriceText(getInputPrice(scope.row)) }}</p>
|
||||
<p>输出价格: {{ formatPriceText(getOutputPrice(scope.row)) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 仅已上架:计费方式 -->
|
||||
<el-table-column v-if="activeStatus === 'listed'" label="计费方式" min-width="110">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" type="info">{{ scope.row.billing_method || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 仅已上架:排序序号(增强字段兼容)
|
||||
<el-table-column v-if="activeStatus === 'listed'" label="排序序号" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="sort-index">{{ getSortOrder(scope.row) }}</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
|
||||
<!-- 更新时间 -->
|
||||
<el-table-column label="更新时间" min-width="160">
|
||||
<template slot-scope="scope">{{ getUpdateTime(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 状态 -->
|
||||
<el-table-column label="状态" min-width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="getListingStatusType(scope.row.listing_status)" effect="light" size="mini">
|
||||
{{ getListingStatusText(scope.row.listing_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 仅已上架:排序操作 -->
|
||||
<el-table-column v-if="activeStatus === 'listed'" label="排序" width="130">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
icon="el-icon-top"
|
||||
:loading="sortLoadingId === scope.row.id && sortAction === 'top'"
|
||||
@click="handleModelTop(scope.row)"
|
||||
>置顶</el-button>
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
icon="el-icon-bottom"
|
||||
:loading="sortLoadingId === scope.row.id && sortAction === 'down'"
|
||||
@click="handleModelMoveDown(scope.row)"
|
||||
>下移</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 操作列:固定列必须放在最后,避免覆盖前面的更新时间列 -->
|
||||
<el-table-column label="操作" fixed="right" :width="activeStatus === 'pending' ? 210 : 130">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="small" @click="openModelDetail(scope.row)">详情</el-button>
|
||||
<template v-if="activeStatus === 'pending'">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
:loading="editLoadingId === scope.row.id"
|
||||
@click="openEditDialog(scope.row)"
|
||||
>编辑</el-button>
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
class="success-text"
|
||||
:loading="listingLoadingId === scope.row.id"
|
||||
@click="handleModelUp(scope.row)"
|
||||
>上架</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
class="warning-text"
|
||||
:loading="listingLoadingId === scope.row.id"
|
||||
@click="handleModelDown(scope.row)"
|
||||
>下架</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="filteredModelList.length"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:page-size="pageSize"
|
||||
:current-page.sync="currentPage"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 弹窗组件(保持不变) -->
|
||||
<model-detail-dialog :visible.sync="detailDialogVisible" :model="currentModel" />
|
||||
<listing-confirm-dialog
|
||||
:visible.sync="listingConfirmVisible"
|
||||
:action="listingConfirmAction"
|
||||
:model="listingConfirmModel"
|
||||
:loading="listingLoadingId === (listingConfirmModel && listingConfirmModel.id)"
|
||||
@close="closeListingConfirm"
|
||||
@confirm="confirmListingAction"
|
||||
/>
|
||||
<add-model-dialog :visible.sync="editDialogVisible" :model-detail="currentEditModel" @submit="handleEditSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
reqModelBottom,
|
||||
reqModelDetail,
|
||||
reqModelDown,
|
||||
reqModelEdit,
|
||||
reqModelList,
|
||||
reqModelTop,
|
||||
reqModelUp
|
||||
} from '@/api/model/model'
|
||||
import ListingConfirmDialog from '@/components/modelManagement/ListingConfirmDialog.vue'
|
||||
import ModelDetailDialog from '@/components/modelManagement/ModelDetailDialog.vue'
|
||||
import ModelFilter from '@/components/modelManagement/ModelFilter.vue'
|
||||
import ModelStats from '@/components/modelManagement/ModelStats.vue'
|
||||
import AddModelDialog from './AddModelDialog.vue'
|
||||
|
||||
export default {
|
||||
name: 'ModelManagement',
|
||||
components: {
|
||||
AddModelDialog,
|
||||
ListingConfirmDialog,
|
||||
ModelDetailDialog,
|
||||
ModelFilter,
|
||||
ModelStats
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tableLoading: false,
|
||||
activeStatus: 'pending',
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
searchForm: { name: '', type: '', provider: '' },
|
||||
detailDialogVisible: false,
|
||||
currentModel: null,
|
||||
editDialogVisible: false,
|
||||
currentEditModel: null,
|
||||
editLoadingId: null,
|
||||
listingLoadingId: null,
|
||||
sortLoadingId: null,
|
||||
sortAction: '',
|
||||
listingConfirmVisible: false,
|
||||
listingConfirmAction: 'up',
|
||||
listingConfirmModel: null,
|
||||
modelList: [],
|
||||
modelTypeOptions: [],
|
||||
providerOptions: [],
|
||||
serverStats: { total: 0, pending: 0, listed: 0 }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredModelList() {
|
||||
const selectedStatus = this.activeStatus === 'pending' ? 0 : 1
|
||||
return this.modelList.filter(model => Number(model.listing_status) === selectedStatus)
|
||||
},
|
||||
pagedModelList() {
|
||||
const start = (this.currentPage - 1) * this.pageSize
|
||||
return this.filteredModelList.slice(start, start + this.pageSize)
|
||||
},
|
||||
modelStats() {
|
||||
return [
|
||||
{ label: '全部模型', value: this.serverStats.total, icon: 'el-icon-cpu', className: 'primary' },
|
||||
{ label: '待上架', value: this.serverStats.pending, icon: 'el-icon-warning', className: 'warning' },
|
||||
{ label: '已上架', value: this.serverStats.listed, icon: 'el-icon-success', className: 'success' }
|
||||
]
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchModelList()
|
||||
},
|
||||
methods: {
|
||||
async fetchModelList() {
|
||||
this.tableLoading = true
|
||||
try {
|
||||
const res = await reqModelList(this.getSearchParams())
|
||||
const data = this.extractModelData(res)
|
||||
this.serverStats = {
|
||||
total: Number(data.total_count || 0),
|
||||
pending: Number(data.pending_count || 0),
|
||||
listed: Number(data.listed_count || 0)
|
||||
}
|
||||
this.modelList = Array.isArray(data.model_list) ? data.model_list : []
|
||||
this.modelTypeOptions = this.buildOptions(data.model_type_list, this.modelList.map(item => item.model_type))
|
||||
this.providerOptions = this.buildOptions(data.provider_list, this.modelList.map(item => item.provider))
|
||||
this.currentPage = 1
|
||||
} catch {
|
||||
this.modelList = []
|
||||
this.modelTypeOptions = []
|
||||
this.providerOptions = []
|
||||
this.serverStats = { total: 0, pending: 0, listed: 0 }
|
||||
this.$message.error('模型列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
this.tableLoading = false
|
||||
}
|
||||
},
|
||||
getSearchParams() {
|
||||
const params = {}
|
||||
const modelName = this.searchForm.name.trim()
|
||||
if (modelName) params.model_name = modelName
|
||||
if (this.searchForm.type) params.model_type = this.searchForm.type
|
||||
if (this.searchForm.provider) params.provider = this.searchForm.provider
|
||||
return params
|
||||
},
|
||||
extractModelData(res) {
|
||||
const data = res?.data ?? res
|
||||
if (data?.model_list) return data
|
||||
if (data?.id) {
|
||||
const listingStatus = Number(data.listing_status)
|
||||
return {
|
||||
total_count: 1,
|
||||
pending_count: listingStatus === 0 ? 1 : 0,
|
||||
listed_count: listingStatus === 1 ? 1 : 0,
|
||||
model_list: [data]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
buildOptions(primaryList, fallbackList) {
|
||||
const list = Array.isArray(primaryList) && primaryList.length ? primaryList : fallbackList
|
||||
return [...new Set((Array.isArray(list) ? list : []).filter(Boolean))]
|
||||
},
|
||||
getFieldValue(row, fields) {
|
||||
if (!row) return ''
|
||||
const source = row.model_info && typeof row.model_info === 'object'
|
||||
? { ...row.model_info, ...row }
|
||||
: row
|
||||
for (const field of fields) {
|
||||
const value = source[field]
|
||||
if (value !== undefined && value !== null && value !== '') return value
|
||||
}
|
||||
return ''
|
||||
},
|
||||
getModelDisplayName(row) {
|
||||
return row.display_name || row.model_name || '-'
|
||||
},
|
||||
getModelId(row) {
|
||||
return row?.id || '-'
|
||||
},
|
||||
getModelType(row) {
|
||||
return this.getFieldValue(row, ['model_type', 'modelType', 'type', 'category', 'model_category']) || '-'
|
||||
},
|
||||
getProvider(row) {
|
||||
return row?.provider || '-'
|
||||
},
|
||||
getInputPrice(row) {
|
||||
return this.getFieldValue(row, [
|
||||
'input_token_price', 'inputTokenPrice',
|
||||
'input_price', 'inputPrice',
|
||||
'prompt_price', 'promptPrice'
|
||||
])
|
||||
},
|
||||
getOutputPrice(row) {
|
||||
return this.getFieldValue(row, [
|
||||
'output_token_price', 'outputTokenPrice',
|
||||
'output_price', 'outputPrice',
|
||||
'completion_price', 'completionPrice'
|
||||
])
|
||||
},
|
||||
// 兼容多种排序字段名
|
||||
getSortOrder(row) {
|
||||
const order = this.getFieldValue(row, ['sort_order', 'sortOrder', 'order', 'sort_index', 'sortIndex'])
|
||||
return order !== '' ? order : '-'
|
||||
},
|
||||
// 兼容多种时间字段名
|
||||
getUpdateTime(row) {
|
||||
if (!row) return '-'
|
||||
const info = row.model_info && typeof row.model_info === 'object' ? row.model_info : {}
|
||||
return row.updated_at
|
||||
|| info.updated_at
|
||||
|| row.update_time
|
||||
|| info.update_time
|
||||
|| row.updatedAt
|
||||
|| info.updatedAt
|
||||
|| row.updateTime
|
||||
|| info.updateTime
|
||||
|| '-'
|
||||
},
|
||||
getListingStatusText(status) {
|
||||
return Number(status) === 1 ? '已上架' : '待上架'
|
||||
},
|
||||
getListingStatusType(status) {
|
||||
return Number(status) === 1 ? 'success' : 'warning'
|
||||
},
|
||||
openModelDetail(row) {
|
||||
this.currentModel = row
|
||||
this.detailDialogVisible = true
|
||||
},
|
||||
async openEditDialog(row) {
|
||||
if (!row?.id) {
|
||||
this.$message.error('缺少模型ID,无法编辑')
|
||||
return
|
||||
}
|
||||
this.editLoadingId = row.id
|
||||
try {
|
||||
const res = await reqModelDetail(row.id)
|
||||
const detail = res?.data?.id ? res.data : (res?.id ? res : null)
|
||||
if (!detail?.id) throw new Error('模型详情为空')
|
||||
this.currentEditModel = detail
|
||||
this.editDialogVisible = true
|
||||
} catch (error) {
|
||||
this.$message.error(error.message || '模型详情加载失败')
|
||||
} finally {
|
||||
this.editLoadingId = null
|
||||
}
|
||||
},
|
||||
async handleEditSubmit(form) {
|
||||
try {
|
||||
const res = await reqModelEdit(form)
|
||||
if (res?.status === false) throw new Error(res.msg || '模型编辑失败')
|
||||
this.editDialogVisible = false
|
||||
this.currentEditModel = null
|
||||
this.$message.success('模型编辑成功')
|
||||
await this.fetchModelList()
|
||||
} catch (error) {
|
||||
this.$message.error(error.message || '模型编辑失败,请稍后重试')
|
||||
}
|
||||
},
|
||||
handleModelUp(row) {
|
||||
this.openListingConfirm(row, 'up')
|
||||
},
|
||||
handleModelDown(row) {
|
||||
this.openListingConfirm(row, 'down')
|
||||
},
|
||||
async handleModelTop(row) {
|
||||
await this.updateModelSort(row, 'top')
|
||||
},
|
||||
async handleModelMoveDown(row) {
|
||||
await this.updateModelSort(row, 'down')
|
||||
},
|
||||
async updateModelSort(row, action) {
|
||||
if (!row?.id) {
|
||||
this.$message.error('缺少模型ID,无法排序')
|
||||
return
|
||||
}
|
||||
const actionText = action === 'top' ? '置顶' : '下移'
|
||||
this.sortLoadingId = row.id
|
||||
this.sortAction = action
|
||||
try {
|
||||
const res = action === 'top' ? await reqModelTop(row.id) : await reqModelBottom(row.id)
|
||||
if (res?.status === false) throw new Error(res.msg || `${actionText}失败`)
|
||||
this.$message.success(`${actionText}成功`)
|
||||
this.activeStatus = 'listed'
|
||||
await this.fetchModelList()
|
||||
} catch (error) {
|
||||
this.$message.error(error.message || `${actionText}失败,请稍后重试`)
|
||||
} finally {
|
||||
this.sortLoadingId = null
|
||||
this.sortAction = ''
|
||||
}
|
||||
},
|
||||
openListingConfirm(row, action) {
|
||||
if (!row?.id) {
|
||||
this.$message.error('缺少模型ID,无法操作')
|
||||
return
|
||||
}
|
||||
this.listingConfirmModel = row
|
||||
this.listingConfirmAction = action
|
||||
this.listingConfirmVisible = true
|
||||
},
|
||||
closeListingConfirm() {
|
||||
if (this.listingLoadingId) return
|
||||
this.listingConfirmVisible = false
|
||||
this.listingConfirmModel = null
|
||||
},
|
||||
async confirmListingAction() {
|
||||
await this.updateModelListingStatus(this.listingConfirmModel, this.listingConfirmAction)
|
||||
},
|
||||
async updateModelListingStatus(row, action) {
|
||||
if (!row?.id) {
|
||||
this.$message.error('缺少模型ID,无法操作')
|
||||
return
|
||||
}
|
||||
const isUp = action === 'up'
|
||||
const actionText = isUp ? '上架' : '下架'
|
||||
this.listingLoadingId = row.id
|
||||
try {
|
||||
const res = isUp ? await reqModelUp(row.id) : await reqModelDown(row.id)
|
||||
if (res?.status === false) throw new Error(res.msg || `${actionText}失败`)
|
||||
this.$message.success(`${actionText}成功`)
|
||||
this.listingConfirmVisible = false
|
||||
this.listingConfirmModel = null
|
||||
this.activeStatus = isUp ? 'listed' : 'pending'
|
||||
await this.fetchModelList()
|
||||
} catch (error) {
|
||||
this.$message.error(error.message || `${actionText}失败,请稍后重试`)
|
||||
} finally {
|
||||
this.listingLoadingId = null
|
||||
}
|
||||
},
|
||||
handleSearch() {
|
||||
this.currentPage = 1
|
||||
this.fetchModelList()
|
||||
},
|
||||
resetSearch() {
|
||||
this.searchForm = { name: '', type: '', provider: '' }
|
||||
this.currentPage = 1
|
||||
this.fetchModelList()
|
||||
},
|
||||
handleTabChange() {
|
||||
this.currentPage = 1
|
||||
},
|
||||
handleSizeChange(size) {
|
||||
this.pageSize = size
|
||||
this.currentPage = 1
|
||||
},
|
||||
handleCurrentChange(page) {
|
||||
this.currentPage = page
|
||||
},
|
||||
formatPrice(value) {
|
||||
return Number(value || 0).toFixed(4)
|
||||
},
|
||||
formatPriceText(value) {
|
||||
if (value === undefined || value === null || value === '') return '-'
|
||||
return `¥${this.formatPrice(value)}/千Token`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 44%, #ffffff 100%);
|
||||
}
|
||||
.model-table-card {
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
|
||||
/deep/ .el-card__body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
.table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1f2d3d;
|
||||
font-size: 18px;
|
||||
}
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.model-table {
|
||||
/deep/ .el-table__header th {
|
||||
color: #475467;
|
||||
background: #f8fbff;
|
||||
}
|
||||
/deep/ .el-table__row:hover > td {
|
||||
background: #f8fbff;
|
||||
}
|
||||
}
|
||||
.model-status-tabs {
|
||||
margin-bottom: 12px;
|
||||
/deep/ .el-tabs__nav-wrap::after {
|
||||
height: 1px;
|
||||
background: #edf1f7;
|
||||
}
|
||||
}
|
||||
.model-tag {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.model-name-text {
|
||||
color: #1f2d3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sort-index {
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
.price-cell p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.success-text {
|
||||
color: #67c23a;
|
||||
}
|
||||
.warning-text {
|
||||
color: #e6a23c;
|
||||
}
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.model-page {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
197
f/web-kboss/src/views/operation/operationReport/index.vue
Normal file
197
f/web-kboss/src/views/operation/operationReport/index.vue
Normal file
@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="operation-report-page">
|
||||
<div class="report-header">
|
||||
<div>
|
||||
<h2>运营报表</h2>
|
||||
<p>模型使用与计费数据概览</p>
|
||||
</div>
|
||||
<el-button size="small" icon="el-icon-document" @click="exportReport">导出报表</el-button>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-title">活跃用户</div>
|
||||
<div class="stat-value">{{ statCards.activeUsers }}</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="stat-title">Token消耗</div>
|
||||
<div class="stat-value">{{ statCards.tokenUsage }}</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="stat-title">Tokens总费用</div>
|
||||
<div class="stat-value">¥{{ statCards.totalFee }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filterForm">
|
||||
<el-form-item label="用户">
|
||||
<el-input v-model="filterForm.userName" placeholder="搜索用户名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型">
|
||||
<el-select v-model="filterForm.modelName" placeholder="全部模型" clearable>
|
||||
<el-option v-for="item in modelOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="支付方式">
|
||||
<el-select v-model="filterForm.paymentMethod" placeholder="全部" clearable>
|
||||
<el-option label="支付宝" value="支付宝" />
|
||||
<el-option label="微信支付" value="微信支付" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="使用时间">
|
||||
<el-date-picker v-model="filterForm.date" type="date" placeholder="年/月/日" value-format="yyyy-MM-dd" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table :data="pagedList" style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="70" />
|
||||
<el-table-column prop="userId" label="用户ID" min-width="110" />
|
||||
<el-table-column prop="userName" label="用户名" min-width="100" />
|
||||
<el-table-column prop="modelName" label="使用模型" min-width="140">
|
||||
<template slot-scope="scope">
|
||||
<el-tag type="info" size="mini">{{ scope.row.modelName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="useTime" label="使用时间" min-width="160" />
|
||||
<el-table-column prop="inputToken" label="输入TOKEN" min-width="110" />
|
||||
<el-table-column prop="outputToken" label="输出TOKEN" min-width="110" />
|
||||
<el-table-column prop="tokenCost" label="TOKEN费用(元)" min-width="120" />
|
||||
<el-table-column prop="paymentMethod" label="支付方式" min-width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="scope.row.paymentMethod === '支付宝' ? 'primary' : 'success'">
|
||||
{{ scope.row.paymentMethod }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="balance" label="账户余额(元)" min-width="120" />
|
||||
</el-table>
|
||||
|
||||
<div class="pager-wrap">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="filteredList.length"
|
||||
:page-size="pageSize"
|
||||
:current-page.sync="currentPage"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "OperationReport",
|
||||
data() {
|
||||
return {
|
||||
pageSize: 10,
|
||||
currentPage: 1,
|
||||
filterForm: {
|
||||
userName: "",
|
||||
modelName: "",
|
||||
paymentMethod: "",
|
||||
date: ""
|
||||
},
|
||||
reportList: [
|
||||
{ userId: "U100001", userName: "张明远", modelName: "MiniMax-M2.7", useTime: "2026-04-20 14:32:18", inputToken: "1,520", outputToken: "2,380", tokenCost: "¥0.047", paymentMethod: "支付宝", balance: "¥87.53" },
|
||||
{ userId: "U100002", userName: "李恩涵", modelName: "DeepSeek-V3.2", useTime: "2026-04-20 14:28:45", inputToken: "3,200", outputToken: "4,500", tokenCost: "¥0.012", paymentMethod: "微信支付", balance: "¥32.18" },
|
||||
{ userId: "U100003", userName: "王建国", modelName: "GLM-5.1", useTime: "2026-04-20 14:15:33", inputToken: "890", outputToken: "1,200", tokenCost: "¥0.017", paymentMethod: "支付宝", balance: "¥185.42" },
|
||||
{ userId: "U100004", userName: "陈晓鸽", modelName: "Qwen3.5-72B", useTime: "2026-04-20 13:58:21", inputToken: "2,100", outputToken: "3,500", tokenCost: "¥0.063", paymentMethod: "支付宝", balance: "¥56.91" },
|
||||
{ userId: "U100005", userName: "赵伟", modelName: "GPT-4", useTime: "2026-04-20 13:45:10", inputToken: "4,500", outputToken: "6,800", tokenCost: "¥0.576", paymentMethod: "微信支付", balance: "¥312.45" },
|
||||
{ userId: "U100006", userName: "刘芳", modelName: "ERNIE-4.5-Turbo", useTime: "2026-04-20 13:30:05", inputToken: "1,800", outputToken: "2,200", tokenCost: "¥0.042", paymentMethod: "支付宝", balance: "¥68.77" }
|
||||
]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
statCards() {
|
||||
return {
|
||||
activeUsers: "1,286",
|
||||
tokenUsage: "3.2M",
|
||||
totalFee: "38,642"
|
||||
};
|
||||
},
|
||||
modelOptions() {
|
||||
return [...new Set(this.reportList.map(item => item.modelName))];
|
||||
},
|
||||
filteredList() {
|
||||
return this.reportList.filter(item => {
|
||||
const matchUser = !this.filterForm.userName || item.userName.includes(this.filterForm.userName);
|
||||
const matchModel = !this.filterForm.modelName || item.modelName === this.filterForm.modelName;
|
||||
const matchPay = !this.filterForm.paymentMethod || item.paymentMethod === this.filterForm.paymentMethod;
|
||||
const matchDate = !this.filterForm.date || item.useTime.startsWith(this.filterForm.date);
|
||||
return matchUser && matchModel && matchPay && matchDate;
|
||||
});
|
||||
},
|
||||
pagedList() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
return this.filteredList.slice(start, start + this.pageSize);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleSearch() {
|
||||
this.currentPage = 1;
|
||||
},
|
||||
resetSearch() {
|
||||
this.filterForm = { userName: "", modelName: "", paymentMethod: "", date: "" };
|
||||
this.currentPage = 1;
|
||||
},
|
||||
handlePageChange(page) {
|
||||
this.currentPage = page;
|
||||
},
|
||||
exportReport() {
|
||||
this.$message.success("报表导出任务已提交");
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.operation-report-page {
|
||||
padding: 20px;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
h2 { margin: 0; font-size: 28px; }
|
||||
p { margin: 6px 0 0; color: #8b95a7; }
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #eceff5;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.stat-title { color: #5f6b7d; margin-bottom: 10px; }
|
||||
.stat-value { font-size: 38px; font-weight: 700; line-height: 1; }
|
||||
.purple .stat-value { color: #7f56d9; }
|
||||
.green .stat-value { color: #16a34a; }
|
||||
.orange .stat-value { color: #ea580c; }
|
||||
.filter-card,
|
||||
.table-card {
|
||||
border: 1px solid #eceff5;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.pager-wrap {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
693
f/web-kboss/src/views/tokenManagement/index.vue
Normal file
693
f/web-kboss/src/views/tokenManagement/index.vue
Normal file
@ -0,0 +1,693 @@
|
||||
<template>
|
||||
<div class="api-key-page">
|
||||
<div class="api-key-shell">
|
||||
<div class="page-header">
|
||||
<div class="title-block">
|
||||
<div class="title-line">
|
||||
<span class="key-icon">
|
||||
<i class="el-icon-key"></i>
|
||||
</span>
|
||||
<h2>API Key 管理</h2>
|
||||
</div>
|
||||
<p>创建和管理访问令牌,用于在外部系统中安全调用你的能力接口。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
class="ghost-btn"
|
||||
icon="el-icon-refresh"
|
||||
:loading="tableLoading"
|
||||
@click="fetchApiKeyList"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="create-btn"
|
||||
icon="el-icon-plus"
|
||||
:loading="createLoading"
|
||||
@click="handleCreateApiKey"
|
||||
>
|
||||
创建新令牌
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="safe-card">
|
||||
<div class="safe-title">
|
||||
<i class="el-icon-warning-outline"></i>
|
||||
<h3>API 密钥安全指南</h3>
|
||||
</div>
|
||||
<div class="safe-list">
|
||||
<div v-for="item in safeTips" :key="item" class="safe-item">
|
||||
<span></span>
|
||||
<p>{{ item }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card" v-for="item in statCards" :key="item.label" :class="item.type">
|
||||
<div class="stat-icon">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h3>令牌列表</h3>
|
||||
<p>查看已创建的访问令牌,后续可接入创建、查看和禁用接口。</p>
|
||||
</div>
|
||||
<span>共 {{ tokenList.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
v-loading="tableLoading"
|
||||
:data="tokenList"
|
||||
class="api-key-table"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="name" label="令牌名称" min-width="220">
|
||||
<template slot-scope="scope">
|
||||
<span class="token-name">{{ scope.row.name || '默认令牌' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="apikey" label="API Key" min-width="260">
|
||||
<template slot-scope="scope">
|
||||
<span class="masked-key">{{ maskApiKey(scope.row.opc_apikey ) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="small" class="view-btn" @click="copyApiKey(scope.row)">复制</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
title="添加令牌"
|
||||
:visible.sync="createDialogVisible"
|
||||
width="420px"
|
||||
custom-class="create-token-dialog"
|
||||
append-to-body
|
||||
@close="resetCreateForm"
|
||||
>
|
||||
<el-form
|
||||
ref="createFormRef"
|
||||
class="create-token-form"
|
||||
:model="createForm"
|
||||
:rules="createRules"
|
||||
label-width="90px"
|
||||
@submit.native.prevent
|
||||
>
|
||||
<el-form-item label="令牌名称" prop="appname">
|
||||
<el-input
|
||||
v-model.trim="createForm.appname"
|
||||
maxlength="64"
|
||||
clearable
|
||||
placeholder="请输入令牌名称"
|
||||
/>
|
||||
<p class="create-token-tip">用于标识当前令牌的业务应用,建议填写便于识别的令牌名称</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button class="dialog-cancel-btn" @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button class="dialog-submit-btn" type="primary" :loading="createLoading" @click="submitCreateApiKey">确定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { reqApikeyList, reqCreateApikey } from '@/api/model/model'
|
||||
|
||||
export default {
|
||||
name: 'TokenManagement',
|
||||
data() {
|
||||
return {
|
||||
tableLoading: false,
|
||||
createLoading: false,
|
||||
createDialogVisible: false,
|
||||
createForm: {
|
||||
appname: ''
|
||||
},
|
||||
createRules: {
|
||||
appname: [
|
||||
{ required: true, message: '请输入令牌名称', trigger: ['blur', 'change'] }
|
||||
]
|
||||
},
|
||||
safeTips: [
|
||||
'请不要与他人共享您的 API Keys,暴露 API Key 可能会破坏您的帐户安全和访问的服务。',
|
||||
'避免将 API Key 暴露在浏览器或其他客户端代码中。',
|
||||
'为了保护您的帐户安全,一旦 API Keys 被发现泄露,平台发现后可能会将其禁用。'
|
||||
],
|
||||
tokenList: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
statCards() {
|
||||
return [
|
||||
{ label: '令牌总数', value: this.tokenList.length, icon: 'el-icon-key', type: 'primary' },
|
||||
{ label: '启用中', value: this.enabledCount, icon: 'el-icon-success', type: 'success' }
|
||||
]
|
||||
},
|
||||
enabledCount() {
|
||||
return this.tokenList.filter(item => this.isEnabled(item)).length
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchApiKeyList()
|
||||
},
|
||||
methods: {
|
||||
getUserId() {
|
||||
return sessionStorage.getItem('userId') || localStorage.getItem('userId') || ''
|
||||
},
|
||||
getUserParams() {
|
||||
const userid = this.getUserId()
|
||||
if (!userid || userid === 'null') {
|
||||
this.$message.warning('请先登录后再管理 API Key')
|
||||
return null
|
||||
}
|
||||
return { userid }
|
||||
},
|
||||
async fetchApiKeyList() {
|
||||
const params = this.getUserParams()
|
||||
if (!params) return
|
||||
|
||||
this.tableLoading = true
|
||||
try {
|
||||
const res = await reqApikeyList(params)
|
||||
if (res && res.status === false) {
|
||||
throw new Error(res.msg || '获取 API Key 列表失败')
|
||||
}
|
||||
this.tokenList = this.normalizeApiKeyList(res)
|
||||
} catch (error) {
|
||||
this.tokenList = []
|
||||
this.$message.error(error && error.message ? error.message : '获取 API Key 列表失败')
|
||||
} finally {
|
||||
this.tableLoading = false
|
||||
}
|
||||
},
|
||||
handleCreateApiKey() {
|
||||
this.createDialogVisible = true
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.createFormRef) {
|
||||
this.$refs.createFormRef.clearValidate()
|
||||
}
|
||||
})
|
||||
},
|
||||
async submitCreateApiKey() {
|
||||
const isValid = await new Promise(resolve => {
|
||||
if (!this.$refs.createFormRef) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
this.$refs.createFormRef.validate(valid => resolve(valid))
|
||||
})
|
||||
if (!isValid) return
|
||||
const appname = this.createForm.appname
|
||||
|
||||
const params = this.getUserParams()
|
||||
if (!params) return
|
||||
|
||||
this.createLoading = true
|
||||
try {
|
||||
const res = await reqCreateApikey({ ...params, appname })
|
||||
if (res && res.status === false) {
|
||||
throw new Error(res.msg || '创建 API Key 失败')
|
||||
}
|
||||
this.$message.success('API Key 创建成功')
|
||||
this.createDialogVisible = false
|
||||
this.resetCreateForm()
|
||||
await this.fetchApiKeyList()
|
||||
} catch (error) {
|
||||
this.$message.error(error && error.message ? error.message : '创建 API Key 失败')
|
||||
} finally {
|
||||
this.createLoading = false
|
||||
}
|
||||
},
|
||||
resetCreateForm() {
|
||||
if (this.$refs.createFormRef) {
|
||||
this.$refs.createFormRef.resetFields()
|
||||
} else {
|
||||
this.createForm.appname = ''
|
||||
}
|
||||
},
|
||||
normalizeApiKeyList(res) {
|
||||
const data = res && res.data !== undefined ? res.data : res
|
||||
const list = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data && data.apikeys)
|
||||
? data.apikeys
|
||||
: Array.isArray(data && data.list)
|
||||
? data.list
|
||||
: Array.isArray(data && data.apikey_list)
|
||||
? data.apikey_list
|
||||
: Array.isArray(data && data.data)
|
||||
? data.data
|
||||
: data && typeof data === 'object'
|
||||
? [data]
|
||||
: []
|
||||
|
||||
return list.map((item, index) => this.normalizeApiKeyItem(item, index))
|
||||
},
|
||||
normalizeApiKeyItem(item, index) {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
id: index + 1,
|
||||
name: `API Key ${index + 1}`,
|
||||
apikey: item,
|
||||
status: 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: item.id || item.apikeyid || item.apikey_id || index + 1,
|
||||
name: item.name || item.api_key_name || item.apikey_name || `API Key ${index + 1}`,
|
||||
apikey: item.apikey || item.apikeyid || item.api_key || item.key || item.token || item.id || '-',
|
||||
created_at: item.created_at || item.create_time || item.updated_at || '',
|
||||
status: item.status !== undefined ? item.status : 1
|
||||
}
|
||||
},
|
||||
isEnabled(row) {
|
||||
return Number(row.status) !== 0 && row.status !== 'disabled'
|
||||
},
|
||||
getStatusText(row) {
|
||||
return this.isEnabled(row) ? '启用' : '禁用'
|
||||
},
|
||||
getStatusType(row) {
|
||||
return this.isEnabled(row) ? 'success' : 'info'
|
||||
},
|
||||
maskApiKey(value) {
|
||||
const key = String(value || '').trim()
|
||||
if (!key) return '-'
|
||||
if (key.length <= 8) return key
|
||||
const prefix = key.slice(0, 6)
|
||||
const suffix = key.slice(-4)
|
||||
return `${prefix}****${suffix}`
|
||||
},
|
||||
copyApiKey(row) {
|
||||
const value = row && row.apikey
|
||||
if (!value || value === '-') {
|
||||
this.$message.warning('暂无可复制的 API Key')
|
||||
return
|
||||
}
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
this.$message.success('API Key 已复制')
|
||||
}).catch(() => {
|
||||
this.copyByInput(value)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.copyByInput(value)
|
||||
},
|
||||
copyByInput(value) {
|
||||
const input = document.createElement('input')
|
||||
input.value = value
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
this.$message.success('API Key 已复制')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.api-key-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 44%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.api-key-shell {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 48px);
|
||||
padding: 24px;
|
||||
color: #1f2d3d;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -80px;
|
||||
right: -80px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: radial-gradient(circle, rgba(64, 158, 255, 0.12) 0%, rgba(64, 158, 255, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1f2d3d;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.key-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: #409eff;
|
||||
font-size: 18px;
|
||||
background: #eef5ff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.title-block p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
border-color: #e4e7ed;
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
border-color: #b3d8ff;
|
||||
}
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 18px rgba(64, 158, 255, 0.24);
|
||||
}
|
||||
|
||||
.safe-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, #f8fbff 0%, #f2f7ff 100%);
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.safe-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
i {
|
||||
color: #409eff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1f2d3d;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.safe-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin-top: 8px;
|
||||
background: #409eff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
width: 100%;
|
||||
// display: grid;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
// grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
width: 48%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px 22px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: #409eff;
|
||||
font-size: 20px;
|
||||
background: #eef5ff;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
&.success .stat-icon {
|
||||
color: #67c23a;
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #303133;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 6px;
|
||||
color: #1f2d3d;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #c0c4cc;
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-table {
|
||||
color: #606266;
|
||||
background: transparent;
|
||||
|
||||
/deep/ .el-table__header th {
|
||||
color: #475467;
|
||||
background: #f8fbff;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
/deep/ .el-table__row {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/deep/ tr:hover > td {
|
||||
background: #f8fbff !important;
|
||||
}
|
||||
|
||||
/deep/ td {
|
||||
color: #606266;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
/deep/ .el-table__empty-block {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/deep/ &::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.token-name {
|
||||
color: #1f2d3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.masked-key,
|
||||
.view-btn {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/deep/ .create-token-dialog {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.18);
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 18px 22px 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
color: #1f2d3d;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 18px 22px 8px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 14px 22px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.create-token-form {
|
||||
/deep/ .el-form-item__label {
|
||||
color: #4b5565;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/deep/ .el-input__inner {
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
border-color: #d8e3f0;
|
||||
}
|
||||
}
|
||||
|
||||
.create-token-tip {
|
||||
margin: 8px 0 0;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog-cancel-btn {
|
||||
min-width: 74px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dialog-submit-btn {
|
||||
min-width: 90px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -40,6 +40,7 @@ from appPublic.jsonConfig import getConfig
|
||||
from appPublic.i18n import getI18N
|
||||
from appPublic.timeUtils import strdate_add
|
||||
from appPublic.rc4 import unpassword, password
|
||||
from appPublic.aes import aes_decode_b64, aes_encode_b64
|
||||
|
||||
|
||||
from ahserver.filedownload import path_encode
|
||||
@ -217,6 +218,8 @@ if __name__ == '__main__':
|
||||
g.KaiYyEnDecryptUtil = KaiYyEnDecryptUtil
|
||||
g.async_post = async_post
|
||||
g.jsonpath = jsonpath
|
||||
g.aes_decode_b64 = aes_decode_b64
|
||||
g.aes_encode_b64 = aes_encode_b64
|
||||
|
||||
i18n = getI18N(path=workdir)
|
||||
info(f'gadget version={__version__}')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user