updata
This commit is contained in:
parent
f6743d34d1
commit
22dc69f919
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
316
f/web-kboss/src/views/login/components/ForgotPasswordDialog.vue
Normal file
316
f/web-kboss/src/views/login/components/ForgotPasswordDialog.vue
Normal file
@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
custom-class="forgot-password-dialog"
|
||||
:visible="visible"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
@open="handleOpen"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div slot="title" class="forgot-dialog-title">
|
||||
<div class="title-icon">
|
||||
<i class="el-icon-lock"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3>重置密码</h3>
|
||||
<p>通过手机号验证码验证身份后设置新密码</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="forgot-form" autocomplete="off">
|
||||
<el-form-item label="手机号" prop="username">
|
||||
<el-input v-model="form.username" clearable autocomplete="off" placeholder="请输入绑定手机号"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="password">
|
||||
<el-input v-model="form.password" clearable show-password autocomplete="new-password" placeholder="请输入新密码"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="vcode">
|
||||
<div class="code-row">
|
||||
<el-input v-model="form.vcode" clearable autocomplete="off" placeholder="请输入验证码"></el-input>
|
||||
<el-button
|
||||
class="code-btn"
|
||||
:disabled="isDisabled || isGettingCode"
|
||||
:loading="isGettingCode"
|
||||
@click="debouncedGetCode"
|
||||
>
|
||||
{{ sendCodeText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div slot="footer" class="forgot-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确认重置</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getPasswordCodeAPI, retrieveCodeAPI } from '@/api/login'
|
||||
|
||||
export default {
|
||||
name: 'ForgotPasswordDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: this.createEmptyForm(),
|
||||
rules: {
|
||||
username: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [{ required: true, message: '请输入新密码', trigger: 'blur' }],
|
||||
vcode: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
},
|
||||
sendCodeText: '获取验证码',
|
||||
isDisabled: false,
|
||||
isGettingCode: false,
|
||||
submitting: false,
|
||||
timeCount: 60,
|
||||
timer: null,
|
||||
debounceTimer: null
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.resetCodeState()
|
||||
clearTimeout(this.debounceTimer)
|
||||
},
|
||||
methods: {
|
||||
handleOpen() {
|
||||
this.resetForm()
|
||||
this.$nextTick(() => {
|
||||
this.$refs.formRef && this.$refs.formRef.clearValidate()
|
||||
})
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:visible', false)
|
||||
this.resetForm()
|
||||
},
|
||||
resetForm() {
|
||||
this.form = this.createEmptyForm()
|
||||
this.submitting = false
|
||||
this.resetCodeState()
|
||||
},
|
||||
createEmptyForm() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
vcode: '',
|
||||
codeid: ''
|
||||
}
|
||||
},
|
||||
debouncedGetCode() {
|
||||
if (this.isDisabled || this.isGettingCode) return
|
||||
|
||||
this.isGettingCode = true
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.getCode()
|
||||
}, 300)
|
||||
},
|
||||
async getCode() {
|
||||
if (!this.form.username || !/^1[3-9]\d{9}$/.test(this.form.username)) {
|
||||
this.isGettingCode = false
|
||||
this.$message.error('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await retrieveCodeAPI({
|
||||
mobile: this.form.username,
|
||||
action_type: 'forgotpassword'
|
||||
})
|
||||
|
||||
if (res.status === true) {
|
||||
this.form.codeid = res.codeid
|
||||
this.startCountdown()
|
||||
this.$message.success('验证码已发送,请注意查收。')
|
||||
return
|
||||
}
|
||||
|
||||
this.$message.error(res.msg || '验证码获取失败')
|
||||
} catch (error) {
|
||||
this.$message.error('验证码获取失败')
|
||||
} finally {
|
||||
this.isGettingCode = false
|
||||
}
|
||||
},
|
||||
startCountdown() {
|
||||
this.timeCount = 59
|
||||
this.isDisabled = true
|
||||
this.sendCodeText = `重新发送${this.timeCount}s`
|
||||
|
||||
clearInterval(this.timer)
|
||||
this.timer = setInterval(() => {
|
||||
if (this.timeCount > 0) {
|
||||
this.timeCount--
|
||||
this.sendCodeText = `重新发送${this.timeCount}s`
|
||||
return
|
||||
}
|
||||
this.resetCodeState()
|
||||
}, 1000)
|
||||
},
|
||||
resetCodeState() {
|
||||
this.sendCodeText = '获取验证码'
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
this.isDisabled = false
|
||||
this.timeCount = 60
|
||||
},
|
||||
handleSubmit() {
|
||||
this.$refs.formRef.validate(async valid => {
|
||||
if (!valid) return
|
||||
if (!this.form.codeid) {
|
||||
this.$message.error('请先获取验证码')
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
try {
|
||||
const res = await getPasswordCodeAPI({
|
||||
mobile: this.form.username,
|
||||
password: this.form.password,
|
||||
codeid: this.form.codeid,
|
||||
vcode: this.form.vcode,
|
||||
action_type: 'forgotpassword'
|
||||
})
|
||||
|
||||
if (res.status === true) {
|
||||
this.$message.success('密码重置成功')
|
||||
this.handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
this.$message.error(res.msg || '密码重置失败')
|
||||
} catch (error) {
|
||||
this.$message.error('密码重置失败')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.forgot-password-dialog {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 24px 28px 18px;
|
||||
background: linear-gradient(135deg, #f4f8ff 0%, #ffffff 70%);
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 24px 28px 8px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 14px 28px 24px;
|
||||
}
|
||||
|
||||
.el-dialog__headerbtn {
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
.title-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: #2f6bff;
|
||||
font-size: 20px;
|
||||
background: #eaf1ff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1f2d3d;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #8a94a6;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-form {
|
||||
.el-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
padding-bottom: 8px;
|
||||
color: #344054;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
height: 42px;
|
||||
border-color: #dfe5ef;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
width: 118px;
|
||||
height: 42px;
|
||||
color: #2f6bff;
|
||||
background: #eef4ff;
|
||||
border-color: #cfe0ff;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #ffffff;
|
||||
background: #2f6bff;
|
||||
border-color: #2f6bff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
.el-button {
|
||||
min-width: 90px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -110,7 +110,7 @@
|
||||
</div>
|
||||
|
||||
<div class="two-btn">
|
||||
<i></i>
|
||||
<span class="forgot-password" @click="forgotPasswordVisible = true">忘记密码?</span>
|
||||
<span :loading="loading" type="primary" class="go-register"
|
||||
style="width: 127px; margin-bottom: 30px; margin-left: 0px" @click.native.prevent="handleRegister"
|
||||
@click="handleRegister">
|
||||
@ -119,37 +119,7 @@
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 重置密码对话框暂时注释 -->
|
||||
<!--
|
||||
<el-dialog title="重置密码" :visible.sync="dialogVisible" width="25%" class="myDialog">
|
||||
<el-form ref="form" :model="form" label-width="100px" :rules="forms">
|
||||
<el-form-item label="用户名:" prop="username" class="rePassword" style="background-color: white">
|
||||
<el-input v-model="form.username" class="name-input" placeholder="请先输入用户名/手机号"
|
||||
style="background-color: white;border:1px solid #d9d9d9;border-radius: 3px;">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码:" prop="password" style="background-color: white">
|
||||
<el-input v-model="form.password" style="border:1px solid #d9d9d9;border-radius: 3px"
|
||||
placeholder="请输入新密码">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码:" prop="vcode" style="background-color: white">
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<el-input style="border:1px solid #d9d9d9;border-radius: 3px" ref="vcode" v-model="form.vcode"
|
||||
placeholder="请输入验证码" name="vcode" type="text" />
|
||||
<el-button type="primary" size="mini" style="height: 40px; margin-left: 10px"
|
||||
:disabled="isDisabled1 || isGettingCode1" @click="debouncedGetCode1">
|
||||
{{ SendCode_text1 }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="dialog-footer">
|
||||
<el-button size="small" @click="cancelReset">取 消</el-button>
|
||||
<el-button type="primary" size="small" @click="handleSubmit">确 定</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
-->
|
||||
<ForgotPasswordDialog :visible.sync="forgotPasswordVisible" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -177,12 +147,10 @@
|
||||
import {
|
||||
getCodeAPI, // 获取验证码API
|
||||
getLogoAPI, // 获取Logo信息API
|
||||
// getPasswordCodeAPI, // 重置密码API
|
||||
logintypeAPI, // 登录类型API
|
||||
loginUserAPI, // 用户登录API
|
||||
reqGetAppidAPI, // 获取微信AppID API
|
||||
reqGetCodeAPI, // 获取微信授权码API
|
||||
// retrieveCodeAPI, // 获取重置密码验证码API,
|
||||
} from "@/api/login";
|
||||
|
||||
import store from "@/store";
|
||||
@ -196,10 +164,11 @@ import { Message } from "element-ui";
|
||||
import router, { resetRouter } from "@/router";
|
||||
import { reqNewHomeFestival } from "@/api/newHome";
|
||||
import { getHomePath } from '@/views/setting/tools'
|
||||
import ForgotPasswordDialog from './components/ForgotPasswordDialog.vue'
|
||||
|
||||
export default {
|
||||
name: "indexNew",
|
||||
components: { BeforeLogin, promotionalInvitationCode },
|
||||
components: { BeforeLogin, promotionalInvitationCode, ForgotPasswordDialog },
|
||||
data() {
|
||||
return {
|
||||
// 微信登录相关
|
||||
@ -231,6 +200,7 @@ export default {
|
||||
|
||||
// 对话框和加载状态
|
||||
loading: false, // 登录按钮加载状态
|
||||
forgotPasswordVisible: false, // 忘记密码弹窗显示状态
|
||||
|
||||
// 登录表单数据
|
||||
loginForm: {
|
||||
@ -258,21 +228,6 @@ export default {
|
||||
capsTooltip: false,
|
||||
passwordType: "password", // 密码输入框类型:password或text
|
||||
|
||||
// 重置密码相关暂时注释
|
||||
// form: {
|
||||
// username: "", // 用户名
|
||||
// vcode: "", // 验证码
|
||||
// password: "", // 新密码
|
||||
// id: "", // 用户ID
|
||||
// codeid: "", // 验证码ID
|
||||
// },
|
||||
|
||||
// forms: {
|
||||
// username: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
// password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
||||
// vcode: [{ required: true, message: "请输入验证码", trigger: "blur" }],
|
||||
// },
|
||||
|
||||
// Logo显示控制
|
||||
isLogo: false,
|
||||
isShowSaleProduct: false, // 是否显示销售产品
|
||||
@ -374,20 +329,6 @@ export default {
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// 重置密码相关方法暂时注释
|
||||
// debouncedGetCode1: function () {
|
||||
// if (this.isDisabled1 || this.isGettingCode1) return;
|
||||
//
|
||||
// this.isGettingCode1 = true;
|
||||
//
|
||||
// clearTimeout(this.debounceTimer1);
|
||||
//
|
||||
// this.debounceTimer1 = setTimeout(() => {
|
||||
// this.getCode1();
|
||||
// this.isGettingCode1 = false;
|
||||
// }, 300);
|
||||
// },
|
||||
|
||||
// 跳转到百度产品页面
|
||||
goBaidu(listUrl, url) {
|
||||
this.$store.commit('setRedirectUrl', url)
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user