Commit 504d28a0 by zhangxingmin

Merge remote-tracking branch 'origin/dev' into prod

parents 06f2713e a112d197
......@@ -13,25 +13,29 @@
"preview": "vite preview",
"report": "npm run build --report"
},
"repository": {
"type": "git",
"url": "https://gitee.com/y_project/RuoYi-Vue.git"
},
"dependencies": {
"@ai-sdk/alibaba": "^1.0.17",
"@element-plus/icons-vue": "2.3.1",
"@vueup/vue-quill": "1.2.0",
"@vueuse/core": "13.3.0",
"ai": "^6.0.168",
"ali-oss": "^6.23.0",
"axios": "1.9.0",
"clipboard": "2.0.11",
"dompurify": "^3.3.3",
"echarts": "5.6.0",
"element-plus": "2.9.9",
"file-saver": "2.0.5",
"file-saver": "^2.0.5",
"fuse.js": "6.6.2",
"highlight.js": "^11.11.1",
"js-beautify": "1.14.11",
"js-cookie": "3.0.5",
"jsencrypt": "3.3.2",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"marked": "^16.4.0",
"nprogress": "0.2.0",
"p-limit": "^7.3.0",
"pinia": "3.0.2",
"splitpanes": "^4.0.4",
"vue": "3.5.16",
......
// src/api/ai/ai.js
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import useUserStore from '@/store/modules/user'
// 加载随机词条列表
export function randList(data) {
return request({
url: '/ai/api/entry/rand/list',
method: 'post',
data: data
})
}
/**
* 获取完整回答(非流式)
*/
export async function getFullAnswer(question, timeout = 300000) {
const userStore = useUserStore();
const token = getToken();
const tenantId = userStore.currentTenant?.apiLoginTenantInfoResponse?.tenantBizId;
const headers = {
'Authorization': `Bearer ${token}`
};
if (tenantId) {
headers['X-Tenant-ID'] = tenantId;
}
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const url = `${baseUrl}/ai/api/api/ai/stream?question=${encodeURIComponent(question)}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
method: 'GET',
headers,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
let errorMessage = response.statusText;
try {
const errorBody = await response.json();
errorMessage = errorBody.msg || errorBody.message || errorMessage;
} catch (e) {}
throw new Error(errorMessage);
}
// 直接返回纯文本
return await response.text();
} finally {
clearTimeout(timeoutId);
}
}
/**
* 查询输出流信息(流式响应)
* 增强错误处理:当HTTP状态非2xx时,解析错误响应体并抛出包含code属性的错误
*/
export function getStream(question, timeout = 300000) {
const userStore = useUserStore()
const token = getToken()
const tenantId = userStore.currentTenant?.apiLoginTenantInfoResponse?.tenantBizId
const headers = {
'Accept': 'text/event-stream',
'Authorization': `Bearer ${token}`
}
if (tenantId) {
headers['X-Tenant-ID'] = tenantId
}
const baseUrl = import.meta.env.VITE_APP_BASE_API
const url = `${baseUrl}/ai/api/api/ai/stream-sse?question=${encodeURIComponent(question)}`;
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
return fetch(url, {
method: 'GET',
headers,
signal: controller.signal
}).then(async (response) => {
console.log(response)
clearTimeout(timeoutId)
if (!response.ok) {
// 尝试解析错误响应体
let errorCode = null
let errorMessage = response.statusText
try {
const errorBody = await response.json()
if (errorBody && errorBody.code !== undefined) {
errorCode = errorBody.code
errorMessage = errorBody.msg || errorBody.message || errorMessage
}
} catch (e) {
// 无法解析JSON,使用默认错误信息
}
const error = new Error(errorMessage)
error.code = errorCode
error.status = response.status
throw error
}
return response
}).finally(() => {
clearTimeout(timeoutId)
})
}
/**
* 获取服务卡片列表(产品列表)
*/
export async function getServiceCardList() {
const userStore = useUserStore()
const token = getToken()
const userId = userStore.userInfo?.userId || userStore.userId || '30'
let cleanToken = token
if (token && token.startsWith('Bearer ')) {
cleanToken = token.slice(7)
}
const headers = {
'Content-Type': 'application/json',
'x-authorization': `sfpfamilyfinancialplanning ${cleanToken}`
}
const url = 'https://hoservice.ydhomeoffice.cn/hoserviceApi/ydServiceCard/serviceCardList'
const body = {
userId: String(userId),
cardType: '11'
}
try {
const response = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('获取服务卡片列表失败:', error)
throw error
}
}
/**
* 启动流式生成,返回 sessionId
*/
export async function startStream(question) {
const userStore = useUserStore();
const token = getToken();
const tenantId = userStore.currentTenant?.apiLoginTenantInfoResponse?.tenantBizId;
const headers = {
'Authorization': `Bearer ${token}`
};
if (tenantId) {
headers['X-Tenant-ID'] = tenantId;
}
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const url = `${baseUrl}/ai/api/api/ai/start-stream`;
const formData = new FormData();
formData.append('question', question);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return data.sessionId;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* 轮询获取流式内容
*/
export async function pollContent(sessionId) {
const userStore = useUserStore();
const token = getToken();
const tenantId = userStore.currentTenant?.apiLoginTenantInfoResponse?.tenantBizId;
const headers = {
'Authorization': `Bearer ${token}`
};
if (tenantId) {
headers['X-Tenant-ID'] = tenantId;
}
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const url = `${baseUrl}/ai/api/api/ai/stream-content?sessionId=${encodeURIComponent(sessionId)}`;
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json(); // { content: "...", finished: true/false }
}
\ No newline at end of file
......@@ -37,6 +37,22 @@ export const constantRoutes = [
}
]
},
// {
// path: '/system',
// component: Layout,
// children: [
// {
// path: 'ai',
// name: 'Ai',
// component: Ai,
// meta: {
// title: '银盾AI',
// icon: 'message' // 【关键修改】补充图标名,避免 undefined
// // 如果项目里有其他 svg 图标,换成对应的文件名(不含.svg)
// }
// }
// ]
// },
{
path: '/login',
component: () => import('@/views/login'),
......
import router from '@/router'
import { ElMessageBox, } from 'element-plus'
import { ElMessageBox } from 'element-plus'
import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { isHttp, isEmpty } from "@/utils/validate"
import { isHttp, isEmpty } from '@/utils/validate'
import defAva from '@/assets/images/profile.jpg'
import usePermissionStore from '@/store/modules/permission'
const useUserStore = defineStore(
'user',
{
const useUserStore = defineStore('user', {
state: () => ({
token: getToken(),
id: '',
......@@ -29,11 +27,13 @@ const useUserStore = defineStore(
const code = userInfo.code
const uuid = userInfo.uuid
return new Promise((resolve, reject) => {
login(username, password, code, uuid).then(res => {
login(username, password, code, uuid)
.then(res => {
setToken(res.data.token)
this.token = res.data.token
resolve()
}).catch(error => {
})
.catch(error => {
reject(error)
})
})
......@@ -41,19 +41,19 @@ const useUserStore = defineStore(
// 获取用户信息
getInfo() {
return new Promise((resolve, reject) => {
getInfo().then(res => {
getInfo()
.then(res => {
const user = res.data.apiLoginUserInfoResponse
let avatar = user.avatar || ""
let avatar = user.avatar || ''
if (!isHttp(avatar)) {
avatar = (isEmpty(avatar)) ? defAva : import.meta.env.VITE_APP_BASE_API + avatar
avatar = isEmpty(avatar) ? defAva : import.meta.env.VITE_APP_BASE_API + avatar
}
// 设置租户列表
this.tenants = res.data.apiLoginTenantResponseList || []
// 尝试从本地存储获取当前租户,否则使用第一个
const savedTenant = JSON.parse(localStorage.getItem('' +
''))
const savedTenant = JSON.parse(localStorage.getItem('' + ''))
const currentTenant = savedTenant || (this.tenants.length > 0 ? this.tenants[0] : null)
if (currentTenant) {
......@@ -74,36 +74,44 @@ const useUserStore = defineStore(
this.nickName = user.nickName
this.avatar = avatar
this.isSuperAdmin = user.isSuperAdmin
console.log('====================================')
console.log('user', user)
console.log('====================================')
/* 初始密码提示 */
if(res.data.isDefaultModifyPwd) {
if (res.data.isDefaultModifyPwd) {
ElMessageBox.confirm('您的密码还是初始密码,请修改密码!', '安全提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
})
.then(() => {
router.push({ name: 'Profile', params: { activeTab: 'resetPwd' } })
}).catch(() => {})
})
.catch(() => {})
}
/* 过期密码提示 */
if(!res.data.isDefaultModifyPwd && res.isPasswordExpired) {
if (!res.data.isDefaultModifyPwd && res.isPasswordExpired) {
ElMessageBox.confirm('您的密码已过期,请尽快修改密码!', '安全提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
})
.then(() => {
router.push({ name: 'Profile', params: { activeTab: 'resetPwd' } })
}).catch(() => {})
})
.catch(() => {})
}
resolve(res)
}).catch(error => {
})
.catch(error => {
reject(error)
})
})
},
//切换租户
switchTenant(tenant) {
return new Promise((resolve) => {
return new Promise(resolve => {
this.currentTenant = tenant
localStorage.setItem('currentTenant', JSON.stringify(tenant))
......@@ -118,7 +126,9 @@ const useUserStore = defineStore(
this.roles = ['ROLE_DEFAULT']
}
usePermissionStore().generateRoutes().then(accessRoutes => {
usePermissionStore()
.generateRoutes()
.then(accessRoutes => {
// 移除旧路由
const currentRoutes = router.getRoutes()
currentRoutes.forEach(route => {
......@@ -168,6 +178,6 @@ const useUserStore = defineStore(
})
}
}
})
})
export default useUserStore
<template>
<div class="ai-container">
<div class="chat-header">
<div class="header-content">
<div class="logo-area">
<svg class="logo-icon" viewBox="0 0 32 32" fill="none">
<path d="M16 2L4 9v14l12 7 12-7V9L16 2z" stroke="#6c5ce7" stroke-width="1.5" fill="none"/>
<path d="M16 2v30M4 9l12 7 12-7" stroke="#6c5ce7" stroke-width="1.5" fill="none"/>
</svg>
<span class="logo-text">银盾AI</span>
</div>
<div class="header-actions">
<button class="action-btn" @click="clearChat" title="清空对话">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
<div class="chat-messages" ref="messagesContainer" @click="handleProductClick">
<div v-for="(msg, idx) in messages" :key="idx" :class="['message', msg.role]">
<div class="message-avatar">
<div v-if="msg.role === 'user'" class="user-avatar">U</div>
<div v-else class="ai-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10 10 10 0 0 0-10-10z"/>
<path d="M12 6v4M12 16h.01"/>
</svg>
</div>
</div>
<!-- 产品列表消息直接渲染HTML,其他消息使用markdown -->
<div class="message-content" v-if="msg.isProductList" v-html="msg.content"></div>
<div class="message-content" v-else v-html="formatMessage(msg.content)"></div>
</div>
<div v-if="isLoading" class="message assistant">
<div class="message-avatar">
<div class="ai-avatar typing">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10 10 10 0 0 0-10-10z"/>
<path d="M12 6v4M12 16h.01"/>
</svg>
</div>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
<div ref="messagesEnd"></div>
</div>
<div class="chat-input-area">
<div class="suggestions-area" v-if="suggestions.length || suggestionsLoading">
<div class="suggestions-header">
<span class="suggestions-title">💡 你可能想问:</span>
<button class="refresh-suggestions" @click="refreshSuggestions" :disabled="suggestionsLoading">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 2v6h-6M4 12a8 8 0 0 1 16 0 8 8 0 0 1-8 8 8 8 0 0 1-8-8z"/>
<path d="M12 8v4l3 3"/>
</svg>
换一批
</button>
</div>
<div class="suggestions-list">
<div v-if="suggestionsLoading" class="suggestions-loading">
<span class="loading-dot"></span><span class="loading-dot"></span><span class="loading-dot"></span>
</div>
<template v-else>
<button
v-for="(item, idx) in suggestions"
:key="idx"
class="suggestion-chip"
@click="handleSuggestionClick(item.content)"
:disabled="isLoading"
>
{{ item.content }}
</button>
<div v-if="!suggestions.length && !suggestionsLoading" class="suggestions-empty">
暂无推荐,点击换一批
</div>
</template>
</div>
</div>
<div class="input-wrapper">
<textarea
v-model="question"
:disabled="isLoading"
placeholder="请输入问题..."
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.shift.exact="question += '\n'"
rows="1"
ref="inputTextarea"
></textarea>
<button class="send-btn" @click="sendMessage" :disabled="isLoading || !question.trim()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<div class="input-hint">Shift + Enter 换行</div>
</div>
</div>
</template>
<script>
import { randList, getFullAnswer, getServiceCardList, startStream, pollContent } from '@/api/ai/ai';
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
// ==================== 强化预处理函数:确保表格正确渲染 + 段落分明 ====================
function preprocessMarkdown(text) {
if (!text) return text;
const lines = text.split('\n');
const result = [];
let inTable = false;
let tableRows = [];
const isTableRow = (line) => {
const trimmed = line.trim();
// 以 | 开头或结尾,且包含至少一个 |,认为是表格行
return (trimmed.startsWith('|') || trimmed.endsWith('|')) && trimmed.includes('|');
};
const fixTableRow = (row) => {
// 确保行以 | 开头,以 | 结尾
let fixed = row.trim();
if (!fixed.startsWith('|')) fixed = '| ' + fixed;
if (!fixed.endsWith('|')) fixed = fixed + ' |';
// 标准化分隔行中的横线
if (fixed.includes('---') || fixed.includes(':---')) {
fixed = fixed.replace(/:\-{3,}/g, ':---').replace(/\-{3,}:?/g, '---:').replace(/\-{3,}/g, '---');
fixed = fixed.replace(/\|/g, ' | ').replace(/\s+/g, ' ').replace(/\|\s*\|\s*/g, '|');
}
return fixed;
};
const flushTable = () => {
if (tableRows.length === 0) return;
// 修复每一行
const fixedRows = tableRows.map(row => fixTableRow(row));
// 确保表格前后有空行
if (result.length > 0 && result[result.length - 1].trim() !== '') {
result.push('');
}
result.push(...fixedRows);
result.push(''); // 表格后空行
tableRows = [];
};
for (let line of lines) {
const isRow = isTableRow(line);
if (isRow) {
if (!inTable) {
// 进入表格区域
inTable = true;
}
tableRows.push(line);
} else {
if (inTable) {
flushTable();
inTable = false;
}
result.push(line);
}
}
if (inTable) {
flushTable();
}
let processed = result.join('\n');
// 将单个换行转换为双换行(段落分隔),但保留表格区域的多行结构(表格行之间是单个\n,marked要求如此)
// 我们已在表格前后添加了空行,这里只需处理非表格区域
const nonTablePartRegex = /(?:^|\n\n)(?!\|)(?:.|\n(?!\n|$))+/g;
processed = processed.replace(nonTablePartRegex, (part) => {
return part.replace(/([^\n])\n([^\n])/g, '$1\n\n$2');
});
return processed;
}
// 配置 marked:关闭 breaks,因为我们已经手动处理了段落(用双换行生成 <p>
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: false, // 不自动转换单个换行,由我们的预处理负责
gfm: true,
tables: true,
mangle: false,
headerIds: false,
})
export default {
name: 'Ai',
data() {
return {
question: '',
messages: [],
isLoading: false,
suggestions: [],
suggestionsLoading: false,
}
},
mounted() {
this.messages.push({
role: 'assistant',
content: '你好!我是 银盾AI助手,有什么可以帮你的吗?'
})
this.loadSuggestions()
},
methods: {
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer
if (container) {
container.scrollTop = container.scrollHeight
}
})
},
async loadSuggestions(randNum = 4) {
this.suggestionsLoading = true
try {
const response = await randList({ randNum })
if (response.code === 200 && Array.isArray(response.data)) {
this.suggestions = response.data.map(item => ({
content: item.content,
id: item.id,
}))
} else {
console.warn('词条接口返回异常:', response)
this.suggestions = []
}
} catch (error) {
console.error('加载词条失败:', error)
this.suggestions = []
} finally {
this.suggestionsLoading = false
}
},
refreshSuggestions() {
if (!this.suggestionsLoading) {
this.loadSuggestions(4)
}
},
handleSuggestionClick(content) {
if (this.isLoading) return
if (content && content.trim()) {
this.question = content.trim()
this.sendMessage()
}
},
// 核心渲染:预处理 + marked 解析
// 在 formatMessage 方法中增加对表格的显式保护
formatMessage(content) {
if (!content) return '';
try {
// 1. 修复被压缩成一行的表格:在管道符之间合理插入换行
let fixedContent = content;
// 检测是否包含表格特征(连续管道符),且缺少换行
if (fixedContent.includes('|') && !fixedContent.includes('\n|')) {
// 将 "||" 视为两个单元格之间的分隔,但实际上是两行表格之间的分隔符缺失
// 更稳妥的方法:根据表头数量推断列数,然后按列数拆分后续数据行
const lines = fixedContent.split('\n');
const newLines = [];
for (let line of lines) {
// 如果这一行包含表格标记但没有任何换行(即整段表格被压缩在一行)
if (line.includes('|') && !line.includes('\n')) {
// 尝试识别表格头部分隔行(例如包含 :--- 或 ---)
const separatorMatch = line.match(/(?:\|[\s:]*[-:]+[\s:]*)+(?=\|)/);
if (separatorMatch) {
const separatorIndex = line.indexOf(separatorMatch[0]);
// 表头部分(分隔行之前的内容)
const headerPart = line.substring(0, separatorIndex).trim();
// 分隔行
const sepLine = separatorMatch[0];
// 数据部分(分隔行之后)
const dataPart = line.substring(separatorIndex + sepLine.length).trim();
// 将表头按 || 分割成单独的行(注意:|| 表示两行表格之间的连接)
// 但更常见的是 "||" 出现在数据行之间
// 重构表格:先将表头行和分隔行加入
if (headerPart) {
newLines.push(headerPart);
}
newLines.push(sepLine);
// 处理数据部分:按 "||" 分割成多行
const dataRows = dataPart.split('||').filter(row => row.trim() !== '');
for (let row of dataRows) {
newLines.push(row.trim());
}
} else {
// 没有分隔行,无法修复,原样保留
newLines.push(line);
}
} else {
newLines.push(line);
}
}
fixedContent = newLines.join('\n');
}
// 2. 调用原有的预处理函数
const preprocessed = preprocessMarkdown(fixedContent);
return marked.parse(preprocessed);
} catch (e) {
console.error('Markdown 渲染失败:', e);
return escapeHtml(content);
}
},
// 生成产品列表 HTML
buildProductListHtml(productData) {
if (!productData || !productData.list || !productData.list.length) {
return '<div class="product-list-empty">暂无产品信息</div>'
}
let html = '<div class="product-list">'
productData.list.forEach(card => {
const mainImage = card.list && card.list[0]?.itemImg || ''
const cardCode = card.list && card.list[0]?.cardCode || ''
const cardName = card.cardName || ''
const price = card.price || '0'
html += `
<div class="product-card" data-product-code="${cardCode}" data-product-name="${cardName}">
${mainImage ? `<div class="product-image"><img src="${mainImage}" alt="${cardName}" loading="lazy"></div>` : ''}
<div class="product-info">
<div class="product-title">${escapeHtml(cardName)}</div>
<div class="product-price">¥${parseFloat(price).toFixed(2)}</div>
<button class="product-buy-btn" data-product-code="${cardCode}" data-product-name="${cardName}">立即购买</button>
</div>
</div>
`
})
html += '</div>'
return html
},
async fetchAndShowProducts(userQuestion) {
this.isLoading = true
try {
const response = await getServiceCardList()
if (response.success && response.data && response.data.list) {
const productHtml = this.buildProductListHtml(response.data)
const messageContent = `
<div class="product-intro">根据您的问题“${escapeHtml(userQuestion)}”,为您推荐以下产品:</div>
${productHtml}
`
this.messages.push({
role: 'assistant',
content: messageContent,
isProductList: true
})
} else {
this.messages.push({
role: 'assistant',
content: '抱歉,获取产品列表失败,请稍后重试。',
isProductList: false
})
}
this.scrollToBottom()
} catch (error) {
console.error('获取产品列表失败:', error)
this.messages.push({
role: 'assistant',
content: '获取产品列表失败,请检查网络后重试。',
isProductList: false
})
this.scrollToBottom()
} finally {
this.isLoading = false
}
},
handleProductClick(event) {
const target = event.target
let productElement = target.closest('.product-buy-btn') || target.closest('.product-card')
if (!productElement) return
let productCode = productElement.dataset.productCode
if (!productCode) {
const card = productElement.closest('.product-card')
if (card) productCode = card.dataset.productCode
}
if (productCode) {
const buyUrl = `https://hoservice.ydhomeoffice.cn/ydMall/productDetail?cardCode=${productCode}`
window.open(buyUrl, '_blank')
} else {
console.warn('缺少产品标识,无法跳转')
}
},
async sendMessage() {
if (!this.question.trim() || this.isLoading) return;
const userQuestion = this.question.trim();
this.messages.push({ role: 'user', content: userQuestion });
this.question = '';
this.scrollToBottom();
this.isLoading = true;
const assistantMessageIndex = this.messages.length;
this.messages.push({ role: 'assistant', content: '', isProductList: false });
try {
// 1. 启动流式生成,获取 sessionId
const { startStream } = await import('@/api/ai/ai');
const sessionId = await startStream(userQuestion);
// 2. 开始轮询
const pollInterval = 300; // 毫秒
const maxPolls = 600; // 最多轮询 600 次(3分钟)
let pollCount = 0;
const poll = async () => {
try {
const { pollContent } = await import('@/api/ai/ai');
const { content, finished } = await pollContent(sessionId);
// 检查敏感词通知标记
if (pollCount === 0 && content.includes('__SENSITIVE_NOTIFICATION__')) {
this.messages.pop();
await this.fetchAndShowProducts(userQuestion);
this.isLoading = false;
return;
}
this.messages[assistantMessageIndex].content = content;
this.scrollToBottom();
if (finished || pollCount >= maxPolls) {
this.isLoading = false;
return;
}
pollCount++;
setTimeout(poll, pollInterval);
} catch (err) {
console.error('轮询出错', err);
this.messages[assistantMessageIndex].content = '❌ 获取内容失败';
this.isLoading = false;
}
};
poll();
} catch (error) {
console.error('启动流式生成失败:', error);
this.messages[assistantMessageIndex].content = '❌ 请求失败';
this.isLoading = false;
}
},
clearChat() {
this.messages = []
this.messages.push({
role: 'assistant',
content: '对话已清空,有什么可以帮你的吗?'
})
this.scrollToBottom()
}
}
}
// HTML 转义函数
function escapeHtml(str) {
if (!str) return ''
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
</script>
<style scoped>
.ai-container {
display: flex;
flex-direction: column;
height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #f0f2f5 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.chat-header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 10;
}
.header-content {
max-width: 900px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
}
.logo-area {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 28px;
height: 28px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
background: linear-gradient(135deg, #6c5ce7, #a855f7);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.action-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 8px;
color: #666;
transition: all 0.2s;
}
.action-btn:hover {
background: #f0f0f0;
color: #6c5ce7;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
.message {
display: flex;
gap: 12px;
margin-bottom: 24px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message.user {
flex-direction: row-reverse;
}
.message-avatar {
flex-shrink: 0;
}
.user-avatar {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
}
.ai-avatar {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #6c5ce7, #a855f7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.message-content {
max-width: 85%;
padding: 12px 16px;
border-radius: 18px;
line-height: 1.6;
font-size: 15px;
}
.message.user .message-content {
background: linear-gradient(135deg, #6c5ce7, #7c3aed);
color: white;
border-bottom-right-radius: 4px;
}
.message.assistant .message-content {
background: white;
color: #2d2d2d;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
/* 产品列表样式 */
.message-content :deep(.product-list) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
margin-top: 12px;
}
.message-content :deep(.product-card) {
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
border: 1px solid #f0f0f0;
}
.message-content :deep(.product-card:hover) {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(108, 92, 231, 0.15);
}
.message-content :deep(.product-image) {
width: 100%;
height: 160px;
overflow: hidden;
background: #f5f5f5;
}
.message-content :deep(.product-image img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.message-content :deep(.product-card:hover .product-image img) {
transform: scale(1.05);
}
.message-content :deep(.product-info) {
padding: 12px;
}
.message-content :deep(.product-title) {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 8px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-content :deep(.product-price) {
font-size: 18px;
font-weight: 700;
color: #6c5ce7;
margin-bottom: 12px;
}
.message-content :deep(.product-buy-btn) {
width: 100%;
padding: 8px 0;
background: linear-gradient(135deg, #6c5ce7, #a855f7);
border: none;
border-radius: 24px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.message-content :deep(.product-buy-btn:hover) {
opacity: 0.9;
}
.message-content :deep(.product-intro) {
font-size: 14px;
color: #6c757d;
margin-bottom: 12px;
padding: 8px 0;
border-bottom: 1px solid #e9ecef;
}
.message-content :deep(.product-list-empty) {
text-align: center;
padding: 32px;
color: #94a3b8;
font-size: 14px;
}
.chat-input-area {
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding: 16px 24px;
position: sticky;
bottom: 0;
}
.suggestions-area {
max-width: 900px;
margin: 0 auto 12px auto;
background: #f8f9fa;
border-radius: 16px;
padding: 12px 16px;
border: 1px solid #e9ecef;
}
.suggestions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
color: #6c757d;
}
.suggestions-title {
font-weight: 500;
}
.refresh-suggestions {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
color: #6c5ce7;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 20px;
transition: all 0.2s;
}
.refresh-suggestions:hover:not(:disabled) {
background: rgba(108, 92, 231, 0.1);
}
.refresh-suggestions:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.suggestions-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
min-height: 36px;
}
.suggestion-chip {
background: white;
border: 1px solid #e2e8f0;
border-radius: 24px;
padding: 6px 14px;
font-size: 13px;
color: #2d3748;
cursor: pointer;
transition: all 0.2s;
max-width: 260px;
white-space: normal;
word-break: break-word;
text-align: left;
line-height: 1.4;
}
.suggestion-chip:hover:not(:disabled) {
background: #6c5ce7;
color: white;
border-color: #6c5ce7;
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(108, 92, 231, 0.2);
}
.suggestion-chip:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.suggestions-loading {
display: flex;
gap: 6px;
padding: 6px 0;
}
.loading-dot {
width: 6px;
height: 6px;
background: #6c5ce7;
border-radius: 50%;
animation: pulse 1.4s infinite ease-in-out;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.suggestions-empty {
font-size: 12px;
color: #94a3b8;
padding: 6px 0;
}
.input-wrapper {
max-width: 900px;
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 12px;
background: #f8f9fa;
border-radius: 24px;
padding: 8px 16px;
border: 1px solid #e9ecef;
transition: all 0.2s;
}
.input-wrapper:focus-within {
border-color: #6c5ce7;
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.1);
}
.input-wrapper textarea {
flex: 1;
border: none;
background: transparent;
padding: 8px 0;
font-size: 15px;
resize: none;
outline: none;
font-family: inherit;
max-height: 120px;
line-height: 1.5;
}
.send-btn {
background: #6c5ce7;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
transition: all 0.2s;
flex-shrink: 0;
}
.send-btn:hover:not(:disabled) {
background: #5b4cc4;
transform: scale(1.02);
}
.send-btn:disabled {
background: #cbd5e0;
cursor: not-allowed;
}
.input-hint {
max-width: 900px;
margin: 8px auto 0;
font-size: 12px;
color: #94a3b8;
text-align: center;
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #6c5ce7;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-8px); opacity: 1; }
}
/* Markdown 内容样式(包括表格) */
.message-content :deep(h1) {
font-size: 1.5em;
font-weight: 600;
margin: 0.8em 0 0.4em;
color: #1a1a2e;
}
.message-content :deep(h2) {
font-size: 1.3em;
font-weight: 600;
margin: 0.7em 0 0.35em;
color: #1a1a2e;
}
.message-content :deep(h3) {
font-size: 1.1em;
font-weight: 600;
margin: 0.6em 0 0.3em;
color: #1a1a2e;
}
.message-content :deep(p) {
margin: 0.6em 0;
}
.message-content :deep(code) {
background-color: #f1f3f5;
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.9em;
color: #e83e8c;
}
.message-content :deep(pre) {
background-color: #1e1e1e;
padding: 16px;
border-radius: 12px;
overflow-x: auto;
margin: 0.8em 0;
}
.message-content :deep(pre code) {
background-color: transparent;
padding: 0;
color: #d4d4d4;
font-size: 13px;
line-height: 1.5;
}
.message-content :deep(ul), .message-content :deep(ol) {
padding-left: 1.5em;
margin: 0.6em 0;
}
.message-content :deep(li) {
margin: 0.25em 0;
}
.message-content :deep(blockquote) {
border-left: 3px solid #6c5ce7;
padding-left: 1em;
margin: 0.8em 0;
color: #6c757d;
font-style: italic;
}
/* 表格样式 - 关键修复 */
.message-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 14px;
display: block;
overflow-x: auto;
}
.message-content :deep(th),
.message-content :deep(td) {
border: 1px solid #dee2e6;
padding: 8px 12px;
text-align: left;
}
.message-content :deep(th) {
background-color: #f8f9fa;
font-weight: 600;
}
.message-content :deep(a) {
color: #6c5ce7;
text-decoration: none;
}
.message-content :deep(a:hover) {
text-decoration: underline;
}
.message-content :deep(hr) {
border: none;
border-top: 1px solid #e9ecef;
margin: 1em 0;
}
.message.user .message-content :deep(code) {
background-color: rgba(255, 255, 255, 0.2);
color: #ffe0b5;
}
.message.user .message-content :deep(pre) {
background-color: rgba(0, 0, 0, 0.3);
}
.message.user .message-content :deep(a) {
color: #ffe0b5;
}
/* 滚动条样式 */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
@media (max-width: 768px) {
.message-content {
max-width: 90%;
}
.chat-messages {
max-width: 100%;
padding: 16px;
}
}
</style>
\ No newline at end of file
......@@ -83,8 +83,8 @@ const router = useRouter()
const { proxy } = getCurrentInstance()
const loginForm = ref({
username: 'admin',
password: '12345',
username: '',
password: '',
rememberMe: false,
code: '',
uuid: ''
......
......@@ -10,6 +10,7 @@
</el-col>
</el-row>
<el-table
height="70%"
:data="tableData"
:span-method="objectSpanMethod"
border
......@@ -52,7 +53,7 @@
:close-on-click-modal="false"
>
<!-- 表格数据 -->
<el-table v-loading="settingLoading" :data="settingList" border ref="settingTableRef">
<el-table v-loading="settingLoading" :data="settingList" border ref="settingTableRef" height="75%">
<el-table-column
label="序号 "
width="55"
......
......@@ -16,7 +16,13 @@
<el-row>
<el-col :span="12">
<div class="commonHeader">产品标题</div>
<el-select
<el-input
v-model="form['apiProductLaunchDto'].title"
placeholder="请输入"
maxlength="20"
show-word-limit
/>
<!-- <el-select
v-model="form['apiProductLaunchDto'].title"
filterable
remote
......@@ -35,7 +41,7 @@
:label="item.productName"
:value="item.productBizId"
/>
</el-select>
</el-select> -->
</el-col>
<el-col :span="24" :class="showNameTip ? '' : 'colBottomGap'">
<div class="nameTip">
......@@ -889,6 +895,7 @@ import useUserStore from '@/store/modules/user'
import { ref, computed, watch, nextTick } from 'vue'
import ImageUpload from '@/components/ImageUpload/index.vue' //图片上传组件
import CategoryTable from '@/components/CategoryTable/index.vue' //图片上传组件
const emit = defineEmits(['handleSuccess'])
const props = defineProps({
// 类型,是新增还是编辑,
......@@ -1099,7 +1106,9 @@ const confirmPlatform = () => {
const choosePlatform = async () => {
platFormOpen.value = true
platFormQueryParams.value.pageNo = 1
if (userStore.isSuperAdmin == '0') {
platFormQueryParams.value.loginTenantBizId = userStore.currentTenant.apiLoginTenantInfoResponse.tenantBizId
}
// 获取列表数据
await getPlatFormList()
}
......
......@@ -7,7 +7,7 @@
</div>
</el-col>
</el-row>
<el-table :data="tableData" :span-method="objectSpanMethod" border style="width: 100%; margin-top: 20px"
<el-table :data="tableData" :span-method="objectSpanMethod" border style="width: 100%; margin-top: 20px" height="70%"
row-key="rowKey">
<!-- 动态生成所有列 -->
<template v-for="column in tableColumns" :key="column.prop">
......
......@@ -42,15 +42,15 @@
label="短标题"
prop="shortTitle"
:show-overflow-tooltip="true"
width="150"
width="200"
/>
<el-table-column label="状态" prop="status" align="left">
<el-table-column label="状态" prop="status" align="left" width="100">
<template #default="scope">
<dict-tag :options="product_launch_status" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createTime" align="left">
<el-table-column label="创建时间" prop="createTime" align="left" sortable >
<template #default="scope">
{{ formatIsoToDateTime(scope.row.createTime) }}
</template>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment