This commit is contained in:
ping 2026-05-23 14:53:09 +08:00
commit 0ea31bd7fe
9 changed files with 1775 additions and 319 deletions

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

@ -72,3 +72,44 @@ export const reqCreateApikey = (params = {}) => {
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

@ -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)
@ -747,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);
@ -932,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>
@ -1163,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

@ -1,7 +1,7 @@
<template>
<div class="api-doc-page">
<header class="doc-nav">
<button type="button" @click="$router.back()">
<button type="button" @click="goBack">
<i class="el-icon-arrow-left"></i>
返回
</button>
@ -9,57 +9,45 @@
</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>MiniMax-M2.5 API 文档</h1>
<p>通过 OpenAI 兼容接口接入模型能力支持对话生成工具调用和流式输出</p>
<div class="quick-tabs">
<h1>{{ apiDoc.model_name }} API 文档</h1>
<p>{{ heroDescription }}</p>
<!-- <div class="quick-tabs">
<span v-for="item in quickTabs" :key="item">{{ item }}</span>
</div>
</div> -->
</section>
<section class="doc-section">
<section v-loading="loading" class="doc-section">
<h2>1. 接口地址</h2>
<p>统一使用 HTTPS 请求所有接口都需要携带平台签发的 API Key</p>
<pre><code>POST https://api.kboss.example.com/v2/chat/completions</code></pre>
<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>2. 模型能力列表</h2>
<el-table :data="capabilityTable" border size="small">
<el-table-column prop="name" label="能力"></el-table-column>
<el-table-column prop="value" label="说明"></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '支持' ? 'success' : 'info'" size="mini">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
<h2>3. Python 示例</h2>
<pre><code>{{ apiDoc.python_code || pythonExample }}</code></pre>
</section>
<section class="doc-section">
<h2>3. 请求参数</h2>
<el-table :data="requestParams" border size="small">
<el-table-column prop="name" label="参数名" width="160"></el-table-column>
<el-table-column prop="type" label="类型" width="120"></el-table-column>
<el-table-column prop="required" label="必填" width="90"></el-table-column>
<el-table-column prop="desc" label="说明"></el-table-column>
</el-table>
</section>
<section class="doc-section">
<h2>4. 请求示例</h2>
<pre><code>{{ requestExample }}</code></pre>
</section>
<section class="doc-section">
<h2>5. 返回示例</h2>
<pre><code>{{ responseExample }}</code></pre>
</section>
<section class="doc-section">
<h2>6. 错误码</h2>
<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>
@ -73,10 +61,22 @@
</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: '支持' },
@ -111,26 +111,81 @@ export default {
"temperature": 0.7,
"stream": false
}'`,
responseExample: `{
"id": "chatcmpl_20260518",
"object": "chat.completion",
"model": "minimax-m2.5",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "这是一款适合企业知识问答和内容生成的通用模型..."
},
"finish_reason": "stop"
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())`
}
],
"usage": {
"prompt_tokens": 24,
"completion_tokens": 68,
"total_tokens": 92
}
}`
},
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()
}
}
}
@ -169,6 +224,10 @@ export default {
margin: 28px auto 0;
}
.doc-alert {
margin-bottom: 18px;
}
.doc-hero,
.doc-section {
padding: 24px;

View File

@ -21,23 +21,25 @@
<div class="history-panel">
<div class="panel-title">最近对话</div>
<div
v-for="item in historyList"
v-for="item in sidebarHistoryList"
:key="item.id"
class="history-item"
:class="{ active: activeHistoryId === item.id }"
@click="activeHistoryId = item.id"
:class="{ active: isActiveHistory(item), 'is-temporary': item.isTemporary }"
@click="loadHistoryMessages(item)"
>
<i class="el-icon-chat-dot-round"></i>
<span>{{ item.title }}</span>
<i v-if="!item.isTemporary" class="el-icon-delete delete-history" @click.stop="deleteHistory(item)"></i>
</div>
<div v-if="sidebarHistoryList.length === 0" class="history-empty">暂无历史对话</div>
</div>
</aside>
<main class="chat-main">
<header class="chat-header">
<div>
<h2>K-Boss AI 模型助手</h2>
<p>选择模型后可在这里进行问答推理方案生成和能力验证</p>
<h2>AI 模型助手</h2>
<p>在这里进行问答推理方案生成和能力验证</p>
</div>
</header>
@ -71,17 +73,22 @@
</div>
<div class="message-bubble">
<div class="message-name">{{ message.role === 'assistant' ? '模型助手' : '我' }}</div>
<div class="message-text">{{ message.content }}</div>
<div v-if="message.loading" class="message-text typing-text">
模型正在思考<span class="loading-dots"></span>
</div>
<div v-else class="message-text">
{{ message.content }}<span v-if="message.streaming" class="stream-cursor"></span>
</div>
</div>
</div>
</section>
<footer class="chat-input-area">
<div class="billing-tip">
<!-- <div class="billing-tip">
<i class="el-icon-info"></i>
<span>体验模型将会消耗 Tokens费用以实际发生为主</span>
<button type="button">计费说明</button>
</div>
</div> -->
<div class="input-shell">
<el-input
v-model="inputValue"
@ -96,7 +103,8 @@
<el-button
type="primary"
icon="el-icon-position"
:disabled="!inputValue.trim()"
:disabled="!inputValue.trim() || sending"
:loading="sending"
@click="sendMessage"
>
发送
@ -110,6 +118,12 @@
<script>
import { mapState } from 'vuex'
import {
reqModelExperienceDeleteHistory,
reqModelExperienceHistoryInfo,
reqModelExperienceLeftHistory
} from '@/api/model/model'
import request from '@/utils/request'
export default {
name: 'ModelExperience',
@ -117,14 +131,16 @@ export default {
return {
inputValue: '',
activeHistoryId: 1,
activeHistoryId: 'new-chat',
currentSessionId: '',
temporaryChatId: 'new-chat',
sending: false,
streamController: null,
streamFlushTimer: null,
streamBufferMap: {},
messages: [],
historyList: [
{ id: 1, title: '模型能力咨询' },
{ id: 2, title: '产品介绍生成' },
{ id: 3, title: '部署方案建议' }
],
historyList: [],
promptList: [
'帮我介绍一下这个模型适合哪些业务场景',
'生成一段模型上架介绍文案',
@ -139,43 +155,537 @@ export default {
}),
siteLogo() {
return this.logoInfoNew?.home?.logoImg || require('@/assets/kyy/LOGO.png')
},
userId() {
const userInfo = this.getStoredJson('user_info')
return sessionStorage.getItem('userid') ||
sessionStorage.getItem('userId') ||
localStorage.getItem('userid') ||
localStorage.getItem('userId') ||
userInfo?.id ||
userInfo?.userid ||
''
},
modelIdentifier() {
const cachedModel = this.getStoredJson('tokenMarketSelectedModel')
return this.$route.query.llmid ||
cachedModel?.llmid ||
this.$route.query.model_id ||
this.$route.query.id ||
cachedModel?.id ||
''
},
sidebarHistoryList() {
const currentItem = this.getCurrentChatItem()
if (!currentItem) return this.historyList
const exists = this.historyList.some(item => item.id === currentItem.id)
if (exists) return this.historyList
return [currentItem, ...this.historyList]
}
},
created() {
this.fetchHistoryList()
},
beforeDestroy() {
this.abortStreamRequest()
this.clearStreamFlushTimer()
},
methods: {
// TOKEN TOKEN
goBack() {
if (this.$route.query.from === 'tokenMarket') {
this.$router.push({ path: '/product', query: { category: 'TOKEN市集' } })
return
}
this.$router.back()
},
// session_id
startNewChat() {
this.abortStreamRequest()
this.clearStreamFlushTimer()
this.streamBufferMap = {}
this.messages = []
this.inputValue = ''
this.currentSessionId = ''
this.activeHistoryId = this.temporaryChatId
this.sending = false
},
//
usePrompt(prompt) {
this.inputValue = prompt
this.sendMessage()
},
sendMessage() {
// session_id
async sendMessage() {
const content = this.inputValue.trim()
if (!content) return
if (!content || this.sending) return
if (!this.userId) {
this.$message.error('缺少用户ID无法发送消息')
return
}
this.messages.push({
id: Date.now(),
id: this.createMessageId(),
role: 'user',
content
})
this.inputValue = ''
this.mockAssistantReply(content)
},
mockAssistantReply(question) {
setTimeout(() => {
this.messages.push({
id: Date.now() + 1,
role: 'assistant',
content: `已收到你的问题:“${question}”。这里后续可以接入真实模型接口,目前先展示模型对话页面效果。`
})
this.sending = true
const loadingId = this.createMessageId()
this.messages.push({
id: loadingId,
role: 'assistant',
content: '',
loading: true
})
this.$nextTick(this.scrollToBottom)
try {
const params = {
message: content,
userid: this.userId
}
if (this.currentSessionId) {
params.session_id = String(this.currentSessionId)
}
await this.sendStreamMessage(params, loadingId)
this.fetchHistoryList()
} catch (error) {
if (error.name !== 'AbortError') {
this.replaceLoadingMessage(loadingId, '请求失败,请稍后重试')
}
} finally {
this.sending = false
this.$nextTick(this.scrollToBottom)
}, 300)
}
},
//
async fetchHistoryList() {
if (!this.userId) return
try {
const res = await reqModelExperienceLeftHistory({ userid: this.userId })
this.historyList = this.normalizeHistoryList(this.extractResponseData(res))
} catch (error) {
this.historyList = []
}
},
// session_id
async loadHistoryMessages(item) {
if (item && item.isTemporary) {
this.activeHistoryId = item.id
return
}
if (!item || !item.id || item.id === this.activeHistoryId) return
this.activeHistoryId = item.id
this.currentSessionId = item.id
try {
const res = await reqModelExperienceHistoryInfo({
userid: this.userId,
session_id: item.id
})
this.messages = this.normalizeMessageList(this.extractResponseData(res))
this.$nextTick(this.scrollToBottom)
} catch (error) {
this.$message.error('历史对话加载失败')
}
},
//
async deleteHistory(item) {
if (!item || !item.id || item.isTemporary) return
try {
const res = await reqModelExperienceDeleteHistory({
userid: this.userId,
session_id: item.id
})
if (res.status === false) {
this.$message.error(res.msg || '删除失败')
return
}
if (this.currentSessionId === item.id) {
this.startNewChat()
}
this.$message.success('删除成功')
this.fetchHistoryList()
} catch (error) {
this.$message.error('删除失败')
}
},
// DeepSeek
getCurrentChatItem() {
if (!this.currentSessionId && this.activeHistoryId !== this.temporaryChatId && this.messages.length === 0) {
return null
}
return {
id: this.currentSessionId || this.temporaryChatId,
title: '新对话',
isTemporary: true
}
},
//
isActiveHistory(item) {
if (!item) return false
if (item.isTemporary && this.currentSessionId) {
return item.id === this.currentSessionId
}
return this.activeHistoryId === item.id
},
//
replaceLoadingMessage(id, content) {
const index = this.messages.findIndex(item => item.id === id)
if (index === -1) return
this.clearStreamBuffer(id)
this.$set(this.messages, index, {
...this.messages[index],
loading: false,
streaming: false,
content: this.displayValue(content)
})
},
// SSE content
async sendStreamMessage(params, messageId) {
this.prepareStreamMessage(messageId)
this.abortStreamRequest()
this.streamController = new AbortController()
const response = await fetch(this.buildStreamUrl(params), {
method: 'GET',
credentials: 'include',
headers: {
client_uuid: sessionStorage.getItem('client_uuid') || ''
},
signal: this.streamController.signal
})
if (!response.ok) {
throw new Error(`stream request failed: ${response.status}`)
}
if (!response.body) {
const text = await response.text()
this.consumeSseText(text, messageId)
this.finishStreamMessage(messageId)
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
buffer = this.consumeSseText(buffer, messageId, true)
}
if (buffer) {
this.consumeSseText(buffer, messageId)
}
this.finishStreamMessage(messageId)
},
// content loading
prepareStreamMessage(id) {
const index = this.messages.findIndex(item => item.id === id)
if (index === -1) return
this.$set(this.messages, index, {
...this.messages[index],
loading: true,
streaming: false,
content: ''
})
},
// SSE 沿 request baseURL
buildStreamUrl(params) {
const baseURL = request.defaults.baseURL || ''
const query = new URLSearchParams()
Object.keys(params).forEach(key => {
if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
query.append(key, params[key])
}
})
return `${baseURL}/cntoai/chat_send_stream.dspy?${query.toString()}`
},
// data: {...} SSE
consumeSseText(text, messageId, keepRemain = false) {
const normalizedText = text.replace(/\r\n/g, '\n')
const blocks = normalizedText.split('\n\n')
const pending = keepRemain ? blocks.pop() : ''
blocks.forEach(block => {
const lines = block.split('\n').filter(line => line.startsWith('data:'))
lines.forEach(line => {
const payload = line.replace(/^data:\s*/, '').trim()
this.handleSsePayload(payload, messageId)
})
})
return pending || ''
},
// SSE data content meta/done session_id
handleSsePayload(payload, messageId) {
if (!payload || payload === '[DONE]') return
try {
const event = JSON.parse(payload)
const sessionId = this.normalizeSessionId(event.session_id || event.sessionId)
if (sessionId) {
this.currentSessionId = sessionId
this.activeHistoryId = sessionId
}
if (event.type === 'content') {
this.appendAssistantContent(messageId, event.content)
}
if (event.type === 'done' && !this.getMessageContent(messageId) && event.reply) {
this.appendAssistantContent(messageId, event.reply)
}
} catch (error) {
this.appendAssistantContent(messageId, payload)
}
},
//
appendAssistantContent(id, content) {
if (content === undefined || content === null || content === '') return
this.streamBufferMap = {
...this.streamBufferMap,
[id]: `${this.streamBufferMap[id] || ''}${content}`
}
this.startStreamFlush(id)
},
//
startStreamFlush(id) {
if (this.streamFlushTimer) return
this.streamFlushTimer = setInterval(() => {
this.flushStreamBuffer(id)
}, 16)
},
// chunk
flushStreamBuffer(id, flushAll = false) {
const buffer = this.streamBufferMap[id] || ''
const index = this.messages.findIndex(item => item.id === id)
if (index === -1) return
if (!buffer) {
if (flushAll) {
this.$set(this.messages, index, {
...this.messages[index],
loading: false,
streaming: false,
content: this.messages[index].content || '模型暂无回复'
})
this.clearStreamFlushTimer()
this.clearStreamBuffer(id)
}
return
}
const step = flushAll ? buffer.length : this.getSmoothStreamStep(buffer)
const nextText = buffer.slice(0, step)
const remainText = buffer.slice(step)
this.$set(this.messages, index, {
...this.messages[index],
loading: false,
streaming: true,
content: `${this.messages[index].content || ''}${nextText}`
})
this.streamBufferMap = {
...this.streamBufferMap,
[id]: remainText
}
if (!remainText && flushAll) {
this.$set(this.messages, index, {
...this.messages[index],
streaming: false
})
this.clearStreamFlushTimer()
this.clearStreamBuffer(id)
}
this.$nextTick(this.scrollToBottom)
},
//
getSmoothStreamStep(buffer) {
if (buffer.length > 120) return 6
if (buffer.length > 50) return 4
return 2
},
//
clearStreamFlushTimer() {
if (this.streamFlushTimer) {
clearInterval(this.streamFlushTimer)
this.streamFlushTimer = null
}
},
//
clearStreamBuffer(id) {
if (!this.streamBufferMap[id]) return
const nextBufferMap = { ...this.streamBufferMap }
delete nextBufferMap[id]
this.streamBufferMap = nextBufferMap
},
// done reply
getMessageContent(id) {
const message = this.messages.find(item => item.id === id)
return message ? message.content : ''
},
// SSE
finishStreamMessage(id) {
this.flushStreamBuffer(id, true)
},
//
abortStreamRequest() {
if (this.streamController) {
this.streamController.abort()
this.streamController = null
}
this.clearStreamFlushTimer()
this.streamBufferMap = {}
},
//
getAssistantReply(data, res) {
return data.reply ||
data.answer ||
data.response ||
data.content ||
data.message ||
res.reply ||
res.answer ||
res.response ||
res.content ||
res.msg ||
'模型暂无回复'
},
// id/title
normalizeHistoryList(data) {
const list = Array.isArray(data)
? data
: data.session_list || data.sessions || data.list || data.data || []
if (!Array.isArray(list)) return []
return list.map((item, index) => {
const id = item.session_id || item.id || item.sessionId
return {
id,
title: item.title || item.session_name || item.name || item.first_message || `历史对话 ${index + 1}`
}
}).filter(item => item.id)
},
//
normalizeMessageList(data) {
const list = Array.isArray(data)
? data
: data.message_list || data.messages || data.list || data.data || []
if (!Array.isArray(list)) return []
return list.reduce((messages, item, index) => {
const question = item.question || item.user_message || item.user_content || item.prompt
const answer = item.answer || item.assistant_message || item.assistant_content || item.reply || item.response
if (question && answer) {
messages.push({
id: this.createMessageId(`${index}_user`),
role: 'user',
content: this.displayValue(question)
})
messages.push({
id: this.createMessageId(`${index}_assistant`),
role: 'assistant',
content: this.displayValue(answer)
})
return messages
}
messages.push({
id: item.id || this.createMessageId(index),
role: this.normalizeRole(item.role || item.sender || item.type),
content: this.displayValue(item.content || item.message || item.text || answer || question)
})
return messages
}, [])
},
// 使 user/assistant
normalizeRole(role) {
return ['user', 'human', 'customer'].includes(String(role).toLowerCase()) ? 'user' : 'assistant'
},
// session_id
normalizeSessionId(value) {
if (value === undefined || value === null || value === '') return ''
if (typeof value === 'object') {
return value.session_id || value.sessionId || value.id || ''
}
return String(value)
},
// res.data data
extractResponseData(res) {
if (!res) return {}
if (res.data !== undefined && res.data !== null) return res.data
return res
},
// JSON
getStoredJson(key) {
try {
return JSON.parse(localStorage.getItem(key) || sessionStorage.getItem(key) || '{}')
} catch (error) {
return {}
}
},
// id key
createMessageId(seed = '') {
return `${Date.now()}_${Math.random().toString(16).slice(2)}_${seed}`
},
// JSON
displayValue(value) {
if (value === undefined || value === null || value === '') return ''
if (typeof value === 'object') return JSON.stringify(value, null, 2)
return value
},
//
scrollToBottom() {
const body = this.$refs.messageBody
if (body) {
@ -299,11 +809,49 @@ export default {
margin-right: 8px;
}
span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover,
&.active {
color: #1e6fff;
background: #f4f8ff;
}
&.is-temporary {
color: #1e6fff;
background: #eef5ff;
border: 1px solid #d7e7ff;
}
&:hover {
.delete-history {
opacity: 1;
}
}
}
.delete-history {
margin: 0 0 0 8px !important;
color: #98a2b3;
opacity: 0;
transition: all 0.2s ease;
&:hover {
color: #f56c6c;
}
}
.history-empty {
padding: 18px 0;
color: #98a2b3;
text-align: center;
font-size: 13px;
}
.chat-main {
@ -461,6 +1009,64 @@ export default {
line-height: 1.7;
}
.typing-text {
color: #8a94a6;
}
.loading-dots::after {
display: inline-block;
width: 18px;
text-align: left;
content: '';
animation: loadingDots 1.2s steps(4, end) infinite;
}
.loading-dots {
display: inline-block;
}
.stream-cursor {
display: inline-block;
width: 7px;
height: 16px;
margin-left: 2px;
vertical-align: -2px;
background: #1e6fff;
border-radius: 2px;
animation: cursorBlink 0.9s infinite;
}
@keyframes loadingDots {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75%,
100% {
content: '...';
}
}
@keyframes cursorBlink {
0%,
45% {
opacity: 1;
}
46%,
100% {
opacity: 0;
}
}
.chat-input-area {
padding: 18px 24px 22px;
background: #ffffff;

View File

@ -1,12 +1,12 @@
<template>
<div class="model-detail-page">
<header class="top-nav">
<button class="back-btn" type="button" @click="$router.back()">
<button class="back-btn" type="button" @click="goBack">
<i class="el-icon-arrow-left"></i>
返回
</button>
<span class="nav-divider"></span>
<span>Token市集</span>
<span class="token-market-link" @click="goTokenMarket">Token市集</span>
<div class="nav-actions">
<span>控制台</span>
<span>用户后台</span>
@ -21,12 +21,10 @@
<div class="model-summary">
<div class="model-title-row">
<h1>{{ modelInfo.name }}</h1>
<el-tag size="mini" type="success">深度推理</el-tag>
<el-tag size="mini" type="success">{{ modelInfo.tag }}</el-tag>
</div>
<div class="model-meta">
<span>中文</span>
<span>英文</span>
<span>128K上下文</span>
<span v-for="item in modelInfo.metaList" :key="item">{{ item }}</span>
</div>
<p>{{ modelInfo.description }}</p>
</div>
@ -39,11 +37,11 @@
模型ID{{ modelInfo.modelId }}
<i class="el-icon-copy-document"></i>
</p>
<p>当前版本能力稳定适合内容生成知识问答工具调用和复杂任务规划</p>
<p>{{ modelInfo.versionDescription }}</p>
</div>
<div class="version-actions">
<el-button size="small" @click="$router.push('/modelApiDocument')">API文档</el-button>
<el-button size="small" type="primary" @click="$router.push('/modelExperience')">体验</el-button>
<el-button size="small" @click="goApiDocument">API文档</el-button>
<el-button size="small" type="primary" @click="goExperience">体验</el-button>
</div>
</section>
@ -70,13 +68,18 @@
<div class="price-list">
<div class="price-item input">
<span>模型输入</span>
<strong>0.0021</strong>
<em>/千Tokens</em>
<strong>{{ modelInfo.inputPrice }}</strong>
<em>{{ modelInfo.priceUnit }}</em>
</div>
<div class="price-item output">
<span>模型输出</span>
<strong>0.0084</strong>
<em>/千Tokens</em>
<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>
@ -86,18 +89,16 @@
<p>{{ modelInfo.longDescription }}</p>
<h3>2. 模型亮点</h3>
<div class="feature-block blue">
<i class="el-icon-cpu"></i>
<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>推理</strong>
<p>擅长处理数学代码逻辑分析和复杂任务拆解适合作为业务助手和智能问答底座</p>
</div>
</div>
<div class="feature-block purple">
<i class="el-icon-magic-stick"></i>
<div>
<strong>模型调优</strong>
<p>提供稳定的上下文理解能力便于后续结合业务数据进行知识增强和场景优化</p>
<strong>{{ item.label }}</strong>
<p>{{ item.value }}</p>
</div>
</div>
</section>
@ -116,7 +117,14 @@ export default {
name: 'MiniMax-M2.5',
modelId: 'abab7c72c278cfba',
description: 'MiniMax-M2.5 是面向复杂任务处理的通用语言模型,适用于知识问答、文案创作、工具调用和办公生产力场景。',
longDescription: 'MiniMax-M2.5 是 MiniMax 推出的新一代旗舰语言模型,致力于提升真实世界复杂任务中的表现。在推理、工具使用和搜索、办公生产力场景中均具备较好的任务完成能力。'
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' },
@ -132,8 +140,161 @@ export default {
{ 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>
@ -179,6 +340,14 @@ export default {
font-size: 13px;
}
.token-market-link {
cursor: pointer;
&:hover {
color: #2f6bff;
}
}
.detail-container {
width: 920px;
margin: 28px auto 0;
@ -283,7 +452,7 @@ export default {
.price-list {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
}
@ -312,6 +481,10 @@ export default {
&.output {
background: #fff0fa;
}
&.cache {
background: #f0fdf4;
}
}
.feature-block {

View File

@ -1,41 +1,6 @@
<template>
<div class="product-service-page">
<!-- <div class="page-hero">
<div class="hero-content">
<span class="hero-tag">Token Market</span>
<h2>token市集</h2>
<p>汇聚云AI 等产品能力按分类快速查找并进入对应服务</p>
</div>
<div class="hero-stats">
<div class="stat-card">
<span>一级分类</span>
<strong>{{ panelData.length }}</strong>
</div>
<div class="stat-card">
<span>当前产品</span>
<strong>{{ currentProducts.length }}</strong>
</div>
<div class="stat-card">
<span>全部产品</span>
<strong>{{ totalProductCount }}</strong>
</div>
</div>
</div>
<div class="page-toolbar">
<div>
<h3>{{ activeCategory || '全部分类' }}</h3>
<p>{{ activeCategorySummary }}</p>
</div>
<el-input
v-model="searchKeyword"
class="product-search"e
clearable
prefix-icon="el-icon-search"
placeholder="搜索产品名称、类型或描述"
></el-input>
</div> -->
<!-- 产品分类导航 -->
<div class="category-nav">
<div v-for="category in panelData"
@ -52,7 +17,48 @@
<!-- 左侧菜单区域只有有二级或三级菜单时才显示 -->
<div v-if="hasMenuSidebar" class="menu-sidebar">
<div class="input">
<template v-if="isTokenMarketActive">
<div class="input">
<el-input
v-model="searchKeyword"
class="product-search"
clearable
prefix-icon="el-icon-search"
placeholder="输入关键词 搜索模型"
></el-input>
</div>
<div class="token-filter-section">
<h4>模型筛选</h4>
<div class="token-filter-grid">
<button
v-for="item in tokenModelTypeList"
:key="item"
class="token-filter-item"
:class="{ active: tokenActiveModelType === item }"
@click="toggleTokenModelType(item)"
>
{{ item }}
</button>
</div>
</div>
<div class="token-filter-section">
<h4>提供方</h4>
<div class="token-filter-grid">
<button
v-for="item in tokenProviderList"
:key="item"
class="token-filter-item"
:class="{ active: tokenActiveProvider === item }"
@click="toggleTokenProvider(item)"
>
{{ item }}
</button>
</div>
</div>
</template>
<template v-else>
<div class="input">
<el-input
v-model="searchKeyword"
class="product-search"
@ -90,12 +96,59 @@
</div>
</div>
</div>
</template>
</div>
<!-- 主内容区 -->
<div class="main-content" :class="{ 'full-width': !hasMenuSidebar }">
<template v-if="isTokenMarketActive">
<!-- <div class="token-market-toolbar">
<span>排序:</span>
<button class="token-sort-btn active">综合排序</button>
<button class="token-sort-btn">体验</button>
</div> -->
<div class="token-market-grid">
<div
v-for="product in displayedTokenProducts"
:key="product.id"
class="token-market-card"
@click="goModelDetail(product)"
>
<div class="token-card-top">
<h3>{{ product.display_name || product.model_name }}</h3>
<!-- <span v-if="product.sort_order <= 10" class="token-new-badge">NEW</span> -->
<!-- <i class="el-icon-more token-more"></i> -->
</div>
<div class="token-tags">
<span>{{ product.model_type || '-' }}</span>
<span>{{ product.billing_method || '-' }}</span>
<span>{{ product.provider || '-' }}</span>
<span v-if="product.llmid">{{ product.llmid }}</span>
</div>
<div class="token-price-line">
<span>输入 ¥{{ formatTokenPrice(product.input_token_price) }}/千Token</span>
<span>输出 ¥{{ formatTokenPrice(product.output_token_price) }}/千Token</span>
</div>
<div class="token-meta">
<span class="token-provider-avatar">{{ getProviderInitial(product.provider) }}</span>
<span>{{ product.provider || '-' }}</span>
</div>
<div class="token-actions">
<button @click.stop="goModelApiDocument(product)">
<i class="el-icon-document"></i>
API文档
</button>
<button class="experience" @click.stop="goModelExperience(product)">
<i class="el-icon-video-play"></i>
体验
</button>
</div>
</div>
</div>
</template>
<!-- 产品网格 -->
<div class="product-grid">
<div v-else class="product-grid">
<div v-for="product in displayedProducts"
:key="product.id"
class="product-card"
@ -126,7 +179,7 @@
</div>
<!-- 空状态 -->
<div v-if="!hasDisplayProducts" class="empty-state">
<div v-if="isTokenMarketActive ? !displayedTokenProducts.length : !hasDisplayProducts" class="empty-state">
<div class="empty-icon">
<i class="el-icon-box"></i>
</div>
@ -139,7 +192,7 @@
</template>
<script>
import { reqNavList, reqNewHomeSync, reqNewHomeFestival ,reqTokenMarket} from "@/api/newHome";
import { reqNavList, reqNewHomeSync, reqNewHomeFestival } from "@/api/newHome";
import { gotoYuanJingAPI } from '@/api/gotoYuanJing'
export default {
@ -152,10 +205,17 @@ export default {
activeThirdId: null,
searchKeyword: '',
currentProducts: [],
tokenList: []
tokenList: [],
tokenModelTypeList: [],
tokenProviderList: [],
tokenActiveModelType: '',
tokenActiveProvider: ''
};
},
computed: {
isTokenMarketActive() {
return this.isTokenMarketCategory(this.activeCategory);
},
currentSubcategories() {
if (!this.activeCategory || !this.panelData.length) return [];
const category = this.panelData.find(item => item.firTitle === this.activeCategory);
@ -205,6 +265,7 @@ export default {
return `当前分类包含 ${subCount} 个子类,${productCount} 个可选产品`;
},
hasMenuSidebar() {
if (this.isTokenMarketActive) return true;
return this.hasSecondLevel || this.hasThirdLevel;
},
isSpecialCategory() {
@ -225,22 +286,113 @@ export default {
loginState() {
const userId = sessionStorage.getItem('userId');
return userId !== null && userId !== 'null' && userId !== '';
},
displayedTokenProducts() {
const keyword = this.searchKeyword.trim().toLowerCase();
return this.tokenList.filter(item => {
const matchKeyword = !keyword || [
item.display_name,
item.model_name,
item.model_type,
item.provider,
item.description
].join(' ').toLowerCase().includes(keyword);
const matchType = !this.tokenActiveModelType || item.model_type === this.tokenActiveModelType;
const matchProvider = !this.tokenActiveProvider || item.provider === this.tokenActiveProvider;
return matchKeyword && matchType && matchProvider;
});
}
},
async mounted() {
await this.loadNavData();
this.initializeDefaultData();
},
created() {
this.getTokenList()
},
methods: {
async getTokenList(){
const res = await reqTokenMarket()
console.log('token市集',res);
if(res.status===true){
this.tokenList = res.data.model_list
}
// TOKEN
isTokenMarketCategory(title) {
return ['TOKEN市集', 'Token市集', 'token市集', 'Token Market'].includes(title);
},
// token_market
setTokenMarketData(category) {
const marketData = category && category.token_market && category.token_market.data
? category.token_market.data
: {};
this.tokenList = Array.isArray(marketData.model_list) ? marketData.model_list : [];
this.tokenModelTypeList = Array.isArray(marketData.model_type_list) ? marketData.model_type_list : [];
this.tokenProviderList = Array.isArray(marketData.provider_list) ? marketData.provider_list : [];
},
//
toggleTokenModelType(type) {
this.tokenActiveModelType = this.tokenActiveModelType === type ? '' : type;
},
//
toggleTokenProvider(provider) {
this.tokenActiveProvider = this.tokenActiveProvider === provider ? '' : provider;
},
// TOKEN 0
formatTokenPrice(value) {
if (value === undefined || value === null || value === '') return '-';
const num = Number(value);
if (Number.isNaN(num)) return value;
return num.toFixed(4).replace(/\.?0+$/, '');
},
// logo
getProviderInitial(provider) {
return provider ? provider.slice(0, 1) : 'M';
},
// TOKEN
goModelDetail(model) {
this.cacheTokenMarketModel(model);
this.$router.push({
name: 'modelDetail',
query: {
id: model.id,
model_id: model.id,
llmid: model.llmid || model.model_name || model.id,
from: 'tokenMarket',
category: 'TOKEN市集'
}
});
},
// TOKEN API API
goModelApiDocument(model) {
this.cacheTokenMarketModel(model);
this.$router.push({
name: 'modelApiDocument',
query: {
id: model.id,
model_id: model.id,
from: 'tokenMarket',
category: 'TOKEN市集'
}
});
},
// TOKEN
goModelExperience(model) {
this.cacheTokenMarketModel(model);
this.$router.push({
name: 'modelExperience',
query: {
id: model.id,
model_id: model.id,
from: 'tokenMarket',
category: 'TOKEN市集'
}
});
},
// id
cacheTokenMarketModel(model) {
if (!model) return;
sessionStorage.setItem('tokenMarketSelectedModel', JSON.stringify(model));
},
// key
@ -269,6 +421,9 @@ export default {
// -
processNavData(data) {
return data.map((category, categoryIndex) => {
if (this.isTokenMarketCategory(category.firTitle)) {
this.setTokenMarketData(category);
}
//
if (!category.uniqueId) {
category.uniqueId = `category_${categoryIndex}_${category.firTitle}`;
@ -308,14 +463,30 @@ export default {
//
initializeDefaultData() {
if (this.panelData.length > 0) {
const firstCategory = this.panelData[0];
this.activeCategory = firstCategory.firTitle;
this.setDefaultSubcategory(firstCategory);
const defaultCategory = this.getDefaultCategory();
this.activeCategory = defaultCategory.firTitle;
this.setDefaultSubcategory(defaultCategory);
}
},
// /product query query TOKEN
getDefaultCategory() {
const queryCategory = this.$route.query.category || this.$route.query.tab;
const categoryFromQuery = queryCategory
? this.panelData.find(item => item.firTitle === queryCategory || this.isTokenMarketCategory(queryCategory) && this.isTokenMarketCategory(item.firTitle))
: null;
const tokenCategory = this.panelData.find(item => this.isTokenMarketCategory(item.firTitle));
return categoryFromQuery || tokenCategory || this.panelData[0];
},
//
setDefaultSubcategory(category) {
if (this.isTokenMarketCategory(category.firTitle)) {
this.activeSubId = null;
this.activeThirdId = null;
this.currentProducts = [];
return;
}
if (category.secMenu && category.secMenu.length > 0) {
let defaultSubItem = category.secMenu[0];
@ -367,6 +538,11 @@ export default {
this.activeCategory = category.firTitle;
this.activeSubId = null;
this.activeThirdId = null;
if (this.isTokenMarketCategory(category.firTitle)) {
this.setTokenMarketData(category);
this.currentProducts = [];
return;
}
this.setDefaultSubcategory(category);
},
async goYuanjing() {
@ -634,6 +810,7 @@ export default {
.menu-sidebar {
width: 280px;
height: calc(100vh - 100px);
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
@ -990,6 +1167,38 @@ export default {
border-radius: 18px;
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
}
.token-filter-section {
margin-top: 18px;
h4 {
margin: 0 0 10px;
color: #98a2b3;
font-size: 13px;
font-weight: 600;
}
}
.token-filter-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.token-filter-item {
padding: 4px 0;
color: #344054;
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
font-size: 14px;
&.active {
color: #1e6fff;
font-weight: 600;
}
}
}
.product-content .subcategory-list,
@ -1009,6 +1218,171 @@ export default {
.product-content .main-content {
min-width: 0;
.token-market-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
color: #667085;
font-size: 14px;
}
.token-sort-btn {
height: 28px;
padding: 0 12px;
color: #667085;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
&.active {
color: #7c3aed;
background: #f5f3ff;
border-color: #c4b5fd;
}
}
.token-market-grid {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.token-market-card {
flex: 0 0 calc((100% - 28px) / 3);
min-width: 0;
padding: 14px;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 12px;
box-shadow: 0 10px 24px rgba(31, 45, 61, 0.05);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #d7e4f5;
transform: translateY(-2px);
box-shadow: 0 16px 32px rgba(31, 45, 61, 0.08);
}
}
.token-card-top {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
h3 {
flex: 1;
margin: 0;
color: #1f2d3d;
font-size: 14px;
font-weight: 700;
line-height: 1.35;
}
}
.token-new-badge {
padding: 1px 6px;
color: #ffffff;
font-size: 10px;
font-weight: 700;
background: #3b82f6;
border-radius: 5px;
}
.token-more {
color: #98a2b3;
transform: rotate(90deg);
}
.token-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px dashed #d8e0ee;
span {
padding: 2px 6px;
color: #667085;
font-size: 11px;
background: #f8fafc;
border: 1px solid #edf1f7;
border-radius: 6px;
}
}
.token-price-line {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #1f2937;
font-size: 12px;
font-weight: 600;
}
.token-meta {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
color: #667085;
font-size: 12px;
}
.token-provider-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #ffffff;
font-size: 10px;
background: #1e6fff;
border-radius: 4px;
}
.token-actions {
display: flex;
gap: 6px;
button {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px;
color: #475467;
font-size: 12px;
background: #ffffff;
border: 1px solid #d8e0ee;
border-radius: 7px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1e6fff;
border-color: #9ec5ff;
background: #f4f8ff;
}
}
.experience {
color: #4f46e5;
border-color: #c7d2fe;
&:hover {
color: #4338ca;
border-color: #a5b4fc;
background: #eef2ff;
}
}
}
.product-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@ -1180,6 +1554,16 @@ export default {
width: 100%;
}
}
.product-content .main-content .token-market-card {
flex-basis: calc((100% - 14px) / 2);
}
}
@media (max-width: 768px) {
.product-content .main-content .token-market-card {
flex-basis: 100%;
}
}
}
</style>

View File

@ -82,7 +82,7 @@
</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.apikey || scope.row.id) }}</span>
<span class="masked-key">{{ maskApiKey(scope.row.opc_apikey ) }}</span>
</template>
</el-table-column>