main #107

Merged
charles merged 18 commits from main into prod 2026-05-23 15:00:44 +08:00
61 changed files with 11523 additions and 680 deletions

View File

@ -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
View 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_keyBearer 令牌,不含 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, '&quot;');
}
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
View 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.dspySSE
"""
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

View 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

View 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

View 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

View 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
View 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='模型对话消息';

View 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

View 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

View 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

View 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

View 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

View 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/completionsaiohttp
参数:
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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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())

View 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

View File

@ -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

View File

@ -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": "阿里云",

View File

@ -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:

View 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
})
}

View File

@ -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,
})
}

View 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
})
}

View File

@ -101,3 +101,12 @@ export const todoCount = () => {
method: 'post',
})
}
// 获取token市集
export const reqTokenMarket = () => {
return request({
url: '/cntoai/model_management_customer_search.dspy',
method: 'get',
})
}

View File

@ -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>

View 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>

View 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>

View 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>

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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: {

View File

@ -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);
});

View File

@ -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();
});
},

View File

@ -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 {

View File

@ -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

View 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>

View File

@ -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, // LogoAPI
// 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", // passwordtext
//
// 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;

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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

View 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>

View File

@ -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__}')