554 lines
16 KiB
Vue
554 lines
16 KiB
Vue
|
<route lang="json5" type="page">
|
|||
|
{
|
|||
|
layout: 'default',
|
|||
|
style: {
|
|||
|
navigationBarHidden: true,
|
|||
|
},
|
|||
|
}
|
|||
|
</route>
|
|||
|
|
|||
|
<template>
|
|||
|
<div class="flex flex-col h-screen bg-gray-50">
|
|||
|
<!-- Navigation Bar -->
|
|||
|
<div class="flex-none flex items-center justify-between px-5 py-3 bg-white shadow-md h-10">
|
|||
|
<image src="/static/aichat/back.png" class="w-2 h-4" @click="goBack" />
|
|||
|
<div class="text-lg font-medium">小墨</div>
|
|||
|
<div class="flex items-center space-x-3">
|
|||
|
<image src="/static/aichat/time.png" class="w-5 h-5" @click="viewHistory" />
|
|||
|
<image src="/static/aichat/new.png" class="w-5 h-5" @click="newChat" />
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 消息区 -->
|
|||
|
<div :class="['flex relative', showActions ? 'h-105' : 'h-130']">
|
|||
|
<!-- 背景层 -->
|
|||
|
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
|||
|
<image src="/static/aichat/logo.png" class="w-20 h-24 mb-4" @click="newChat" />
|
|||
|
<view class="text-xl font-medium mb-1">嗨! 我是小墨</view>
|
|||
|
<view class="text-gray-400">开启新的聊天吧</view>
|
|||
|
</div>
|
|||
|
z
|
|||
|
<div
|
|||
|
ref="scrollEl"
|
|||
|
class="flex-1 overflow-y-auto bg-gray-50"
|
|||
|
:class="showActions ? 'pb-44' : 'pb-16'"
|
|||
|
>
|
|||
|
<div :class="['relative z-10 px-4 py-6', showActions ? 'mb--11 h-105' : 'mb--21 h-135']">
|
|||
|
<template v-for="(msg, idx) in messages" :key="idx">
|
|||
|
<view v-if="shouldShowTimestamp(idx)" class="text-center text-xs text-gray-500 my-2">
|
|||
|
{{ formatDayGroup(msg.timestamp) }}
|
|||
|
</view>
|
|||
|
<view
|
|||
|
class="flex items-start"
|
|||
|
:class="msg.role === 'assistant' ? 'justify-start' : 'justify-end'"
|
|||
|
>
|
|||
|
<image
|
|||
|
v-if="msg.role === 'assistant'"
|
|||
|
:src="assistantAvatar"
|
|||
|
class="w-8 h-8 rounded-full mr-2 mt-1"
|
|||
|
/>
|
|||
|
<view class="relative max-w-[70%] mt-4 mb-3">
|
|||
|
<view
|
|||
|
:class="[
|
|||
|
'absolute -top-4 text-xs text-gray-400',
|
|||
|
msg.role === 'assistant' ? 'left-0' : 'right-0',
|
|||
|
]"
|
|||
|
>
|
|||
|
{{ formatTimeShort(msg.timestamp) }}
|
|||
|
</view>
|
|||
|
<view
|
|||
|
:class="[
|
|||
|
'py-2 px-3 rounded-lg break-words mt-1',
|
|||
|
msg.role === 'assistant'
|
|||
|
? 'bg-[#f9f8fd] text-black shadow'
|
|||
|
: 'bg-[#45299e] text-white',
|
|||
|
]"
|
|||
|
>
|
|||
|
{{ msg.content }}
|
|||
|
</view>
|
|||
|
<view
|
|||
|
v-if="msg.role === 'assistant' && msg.type === 'text'"
|
|||
|
class="absolute bottom-0 flex space-x-3"
|
|||
|
>
|
|||
|
<image src="/static/aichat/copy.png" class="w-4 h-4" @click="copyText(msg)" />
|
|||
|
<image
|
|||
|
src="/static/aichat/resect.png"
|
|||
|
class="w-4 h-4"
|
|||
|
@click="refreshText(msg)"
|
|||
|
/>
|
|||
|
</view>
|
|||
|
</view>
|
|||
|
<image
|
|||
|
v-if="msg.role === 'user'"
|
|||
|
:src="userAvatar"
|
|||
|
class="w-8 h-8 rounded-full ml-2 mt-1"
|
|||
|
/>
|
|||
|
</view>
|
|||
|
</template>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 底部上传预览 + 输入区 -->
|
|||
|
<div
|
|||
|
:class="[
|
|||
|
'fixed bottom-0 left-0 right-0 bg-white z-[80] overflow-hidden transition-all duration-300',
|
|||
|
showActions ? 'h-45' : 'h-20',
|
|||
|
]"
|
|||
|
>
|
|||
|
<!-- 上传列表 -->
|
|||
|
<div v-if="uploadList.length" class="flex px-4 py-2 overflow-x-auto space-x-3 bg-white">
|
|||
|
<div
|
|||
|
v-for="item in uploadList"
|
|||
|
:key="item.id"
|
|||
|
class="relative w-16 h-16 rounded overflow-hidden"
|
|||
|
>
|
|||
|
<!-- 预览图,成功后用后端返回的 URL;上传中可以先用本地预览 -->
|
|||
|
<img
|
|||
|
:src="item.url || item.localPath"
|
|||
|
class="w-full h-full object-cover"
|
|||
|
@click="previewImage(item.url || item.localPath)"
|
|||
|
/>
|
|||
|
|
|||
|
<!-- 关闭按钮 -->
|
|||
|
<div
|
|||
|
class="absolute top-1 right-1 w-4 h-4 rounded-full bg-black bg-opacity-50 flex items-center justify-center cursor-pointer text-white text-xs"
|
|||
|
@click="removeImage(item.id)"
|
|||
|
>
|
|||
|
×
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 进度 / 成功 / 失败 -->
|
|||
|
<div
|
|||
|
class="absolute bottom-0 left-0 w-full text-xs text-center text-white py-1"
|
|||
|
:class="{
|
|||
|
'bg-black bg-opacity-50': item.status === 'uploading',
|
|||
|
'bg-green-600 bg-opacity-50': item.status === 'success',
|
|||
|
'bg-red-600 bg-opacity-50': item.status === 'fail',
|
|||
|
}"
|
|||
|
>
|
|||
|
<template v-if="item.status === 'uploading'">{{ item.progress }}%</template>
|
|||
|
<template v-else-if="item.status === 'success'">✔ 成功</template>
|
|||
|
<template v-else>
|
|||
|
✖ 失败
|
|||
|
<span class="cursor-pointer" @click.stop="retry(item)">↻</span>
|
|||
|
</template>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 输入 + 切换 -->
|
|||
|
<view class="flex items-center px-4 py-2.5 border-t border-solid border-[#E7E7E7]">
|
|||
|
<input
|
|||
|
v-model="inputText"
|
|||
|
@keyup.enter="sendText"
|
|||
|
placeholder="想对我说点什么~"
|
|||
|
class="flex-1 h-10 px-3 border border-gray-100 bg-[#f9f9f9] rounded-full focus:outline-none"
|
|||
|
/>
|
|||
|
<image src="/static/aichat/add-circle.png" class="w-7 h-7 mx-3" @click="toggleActions" />
|
|||
|
<image
|
|||
|
src="/static/aichat/enter.png"
|
|||
|
class="w-7 h-7"
|
|||
|
@click="sendText"
|
|||
|
:disabled="loading"
|
|||
|
/>
|
|||
|
</view>
|
|||
|
|
|||
|
<!-- 操作面板 -->
|
|||
|
<transition name="slide-up">
|
|||
|
<view
|
|||
|
v-show="showActions"
|
|||
|
class="flex justify-around items-center h-20 border-t border-solid border-[#E7E7E7] bg-white"
|
|||
|
>
|
|||
|
<view class="flex flex-col items-center">
|
|||
|
<image src="/static/aichat/phone-img.png" class="w-13 h-13" @click="onPickImage" />
|
|||
|
<span class="text-xs mt-1 text-gray-500">照片</span>
|
|||
|
</view>
|
|||
|
<view class="flex flex-col items-center">
|
|||
|
<image src="/static/aichat/photo.png" class="w-13 h-13" @click="onTakePhoto" />
|
|||
|
<span class="text-xs mt-1 text-gray-500">拍摄</span>
|
|||
|
</view>
|
|||
|
<view class="flex flex-col items-center">
|
|||
|
<image src="/static/aichat/files.png" class="w-13 h-13" @click="onPickFile" />
|
|||
|
<span class="text-xs mt-1 text-gray-500">文件</span>
|
|||
|
</view>
|
|||
|
</view>
|
|||
|
</transition>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
1
|
|||
|
</template>
|
|||
|
|
|||
|
<script lang="ts" setup>
|
|||
|
import { ref, reactive, nextTick } from 'vue'
|
|||
|
import dayjs from 'dayjs'
|
|||
|
import { useUserStore } from '@/store'
|
|||
|
import { getEnvBaseUrl } from '@/utils'
|
|||
|
import type { IGptRequestBody } from '@/service/index/foo'
|
|||
|
interface IUpload {
|
|||
|
id: number
|
|||
|
url: string
|
|||
|
filePath: string
|
|||
|
status: 'uploading' | 'success' | 'fail'
|
|||
|
progress: number
|
|||
|
detail: string
|
|||
|
mask: string
|
|||
|
}
|
|||
|
interface IMessage {
|
|||
|
role: 'user' | 'assistant'
|
|||
|
type: 'text' | 'images'
|
|||
|
content: string | string[]
|
|||
|
timestamp: Date
|
|||
|
}
|
|||
|
interface UploadItem {
|
|||
|
id: string
|
|||
|
localPath: string // 本地临时路径,用于预览
|
|||
|
url: string // 后端返回的在线 URL
|
|||
|
status: 'uploading' | 'success' | 'fail'
|
|||
|
progress: number // 上传进度 %
|
|||
|
}
|
|||
|
const assistantAvatar =
|
|||
|
'https://dci-file-new.bj.bcebos.com/fonchain-main/test/runtime/image/avatar/40/b8ed6fea-6662-416d-8bb3-1fd8a8197061.jpg'
|
|||
|
const userAvatar = assistantAvatar
|
|||
|
const baseUrl = getEnvBaseUrl()
|
|||
|
const token = useUserStore().userInfo.token || import.meta.env.VITE_DEV_TOKEN || ''
|
|||
|
const messages = reactive<IMessage[]>([])
|
|||
|
const inputText = ref('')
|
|||
|
const loading = ref(false)
|
|||
|
const showActions = ref(false)
|
|||
|
const scrollEl = ref<HTMLElement>()
|
|||
|
const uploadList = reactive<IUpload[]>([])
|
|||
|
const uploadId = ref(0)
|
|||
|
|
|||
|
function scrollToBottom() {
|
|||
|
const el = scrollEl.value!
|
|||
|
nextTick(() => {
|
|||
|
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
|||
|
})
|
|||
|
}
|
|||
|
function addMessage(msg: IMessage) {
|
|||
|
messages.push(msg)
|
|||
|
scrollToBottom()
|
|||
|
}
|
|||
|
|
|||
|
const shouldShowTimestamp = (i: number) => {
|
|||
|
if (i === 0) return true
|
|||
|
return !dayjs(messages[i].timestamp).isSame(messages[i - 1].timestamp, 'day')
|
|||
|
}
|
|||
|
const formatDayGroup = (d: Date) => dayjs(d).format('YYYY/MM/DD HH:mm')
|
|||
|
const formatTimeShort = (d: Date) => dayjs(d).format('MM/DD HH:mm')
|
|||
|
|
|||
|
function goBack() {
|
|||
|
window.history.back()
|
|||
|
}
|
|||
|
function viewHistory() {
|
|||
|
uni.navigateTo({ url: '/pages/history/history' })
|
|||
|
}
|
|||
|
function newChat() {
|
|||
|
messages.splice(0)
|
|||
|
inputText.value = ''
|
|||
|
}
|
|||
|
|
|||
|
function toggleActions() {
|
|||
|
showActions.value = !showActions.value
|
|||
|
scrollToBottom()
|
|||
|
}
|
|||
|
|
|||
|
// 相册
|
|||
|
function onPickImage() {
|
|||
|
uni.chooseImage({
|
|||
|
count: 10,
|
|||
|
success: (res: any) => {
|
|||
|
res.tempFilePaths.forEach((path) => {
|
|||
|
uploadId.value += 1
|
|||
|
const id = uploadId.value
|
|||
|
const upload: IUpload = { id, url: path, filePath: path, status: 'uploading', progress: 0 }
|
|||
|
uploadList.push(upload)
|
|||
|
uploadFile(path, upload)
|
|||
|
})
|
|||
|
},
|
|||
|
})
|
|||
|
}
|
|||
|
// 拍照
|
|||
|
function onTakePhoto() {
|
|||
|
uni.chooseImage({
|
|||
|
sourceType: ['camera'],
|
|||
|
count: 1,
|
|||
|
success: (res: any) => {
|
|||
|
const path = res.tempFilePaths[0]
|
|||
|
uploadId.value += 1
|
|||
|
const id = uploadId.value
|
|||
|
const upload: IUpload = { id, url: path, filePath: path, status: 'uploading', progress: 0 }
|
|||
|
uploadList.push(upload)
|
|||
|
uploadFile(path, upload)
|
|||
|
},
|
|||
|
})
|
|||
|
}
|
|||
|
// 文件
|
|||
|
// 触发文件选择后,或拍照后,或其它入口都调用这个
|
|||
|
function onPickFile(path: string, detail = '') {
|
|||
|
const id = Date.now().toString()
|
|||
|
const item: UploadItem = {
|
|||
|
id,
|
|||
|
localPath: path,
|
|||
|
url: '',
|
|||
|
status: 'uploading',
|
|||
|
progress: 0,
|
|||
|
}
|
|||
|
uploadList.push(item)
|
|||
|
startUpload(item, detail)
|
|||
|
}
|
|||
|
|
|||
|
interface UploadFile {
|
|||
|
uid: string
|
|||
|
name: string
|
|||
|
size: number
|
|||
|
progress: number // 上传进度 0-100
|
|||
|
status: 'uploading' | 'success' | 'error'
|
|||
|
file: File
|
|||
|
url?: string // 本地预览URL或服务器返回的URL
|
|||
|
}
|
|||
|
const filesList = ref<UploadFile[]>([])
|
|||
|
async function startUpload(item: UploadItem, detail: string) {
|
|||
|
const userStore = useUserStore()
|
|||
|
|
|||
|
// 标记开始上传
|
|||
|
item.status = 'uploading'
|
|||
|
item.progress = 0
|
|||
|
|
|||
|
// 发起上传 (不带 success/fail 回调)
|
|||
|
// @ts-ignore: uni.uploadFile 返回 UploadTask 兼具 Promise 接口
|
|||
|
const uploadTask: UniApp.UploadTask & Promise<UniApp.UploadFileRes> = uni.uploadFile({
|
|||
|
// url: 'http://114.218.158.24:9020/upload/multi',
|
|||
|
url: 'https://ukw0y1.laf.run/upload',
|
|||
|
filePath: item.localPath,
|
|||
|
name: 'k1', // 这里的 name 依然是 formData 里的字段名,后端会以此为 key
|
|||
|
formData: {
|
|||
|
source: 'chat',
|
|||
|
mask: '2076',
|
|||
|
detail,
|
|||
|
type: 'image',
|
|||
|
},
|
|||
|
})
|
|||
|
|
|||
|
// 监听进度
|
|||
|
uploadTask.onProgressUpdate((res: { progress: number }) => {
|
|||
|
item.progress = res.progress
|
|||
|
})
|
|||
|
|
|||
|
// 3s 超时自动 abort
|
|||
|
const timeoutId = setTimeout(() => {
|
|||
|
if (item.status === 'uploading') {
|
|||
|
uploadTask.abort()
|
|||
|
item.status = 'fail'
|
|||
|
}
|
|||
|
}, 3000)
|
|||
|
|
|||
|
try {
|
|||
|
// 等待上传完成
|
|||
|
const res = await uploadTask
|
|||
|
clearTimeout(timeoutId)
|
|||
|
|
|||
|
// 解析后端 JSON
|
|||
|
const resp = JSON.parse(res.data) as {
|
|||
|
code: number
|
|||
|
data: Record<string, string>
|
|||
|
}
|
|||
|
console.log(resp, 'resp')
|
|||
|
if (resp.code === 0 && resp.data) {
|
|||
|
// 遍历 data 对象,找第一个非空值
|
|||
|
const urls = Object.values(resp.data).filter((u) => !!u)
|
|||
|
if (urls.length > 0) {
|
|||
|
item.url = urls[0]
|
|||
|
item.status = 'success'
|
|||
|
} else {
|
|||
|
console.warn('没有拿到任何上传后的 URL')
|
|||
|
item.status = 'fail'
|
|||
|
}
|
|||
|
} else {
|
|||
|
console.warn('后端返回异常 code=', resp.code)
|
|||
|
item.status = 'fail'
|
|||
|
}
|
|||
|
} catch (err) {
|
|||
|
clearTimeout(timeoutId)
|
|||
|
console.error('uploadFile 出错:', err)
|
|||
|
item.status = 'fail'
|
|||
|
}
|
|||
|
}
|
|||
|
// 点击重试
|
|||
|
function retry(item: UploadItem) {
|
|||
|
item.status = 'uploading'
|
|||
|
item.progress = 0
|
|||
|
startUpload(item, '') // 如果需要 detail,可缓存后传入
|
|||
|
}
|
|||
|
// 删除
|
|||
|
function removeImage(id: string) {
|
|||
|
const idx = uploadList.findIndex((i) => i.id === id)
|
|||
|
if (idx >= 0) uploadList.splice(idx, 1)
|
|||
|
}
|
|||
|
// 预览(可自行实现 uni.previewImage 等)
|
|||
|
function previewImage(url: string) {
|
|||
|
uni.previewImage({ urls: [url] })
|
|||
|
}
|
|||
|
// 上传使用 postUpload
|
|||
|
async function uploadFile(path: string, upload: IUpload) {
|
|||
|
// 标记开始
|
|||
|
upload.status = 'uploading'
|
|||
|
|
|||
|
try {
|
|||
|
// @ts-ignore uni.uploadFile 返回 Promise
|
|||
|
const res: UniApp.UploadFileRes = await uni.uploadFile({
|
|||
|
// url: 'http://114.218.158.24:9020/upload/multi',
|
|||
|
url: 'https://ukw0y1.laf.run/upload',
|
|||
|
filePath: path,
|
|||
|
name: 'file', // 这个 name 依然是 formData key,后端会把它用在 data.data 对象里
|
|||
|
formData: {
|
|||
|
source: 'chat',
|
|||
|
mask: '2076',
|
|||
|
type: 'image',
|
|||
|
k1: 'xxxx.png',
|
|||
|
k2: 'xxxx.png',
|
|||
|
},
|
|||
|
})
|
|||
|
|
|||
|
// 解析后端 JSON
|
|||
|
const resp = JSON.parse(res.data) as {
|
|||
|
code: number
|
|||
|
data: Record<string, string>
|
|||
|
}
|
|||
|
|
|||
|
if (resp.code === 0 && resp.data) {
|
|||
|
// 找到 data 对象里第一个有值的字段并赋给 upload.url
|
|||
|
let found = false
|
|||
|
for (const key in resp.data) {
|
|||
|
const url = resp.data[key]
|
|||
|
if (url) {
|
|||
|
upload.url = url
|
|||
|
found = true
|
|||
|
break
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
if (found) {
|
|||
|
upload.status = 'success'
|
|||
|
} else {
|
|||
|
console.warn('没有取到任何上传后的 URL')
|
|||
|
upload.status = 'fail'
|
|||
|
}
|
|||
|
} else {
|
|||
|
console.warn('后端返回异常 code=', resp.code)
|
|||
|
upload.status = 'fail'
|
|||
|
}
|
|||
|
} catch (err) {
|
|||
|
console.error('uploadFile 出错:', err)
|
|||
|
upload.status = 'fail'
|
|||
|
}
|
|||
|
}
|
|||
|
async function sendText() {
|
|||
|
const text = inputText.value.trim()
|
|||
|
if (!text || loading.value) return
|
|||
|
|
|||
|
addMessage({ role: 'user', type: 'text', content: text, timestamp: new Date() })
|
|||
|
inputText.value = ''
|
|||
|
loading.value = true
|
|||
|
|
|||
|
const aiMsg: IMessage = {
|
|||
|
role: 'assistant',
|
|||
|
type: 'text',
|
|||
|
content: '',
|
|||
|
timestamp: new Date(),
|
|||
|
}
|
|||
|
addMessage(aiMsg)
|
|||
|
|
|||
|
const body: IGptRequestBody = {
|
|||
|
model: 'gpt-4-vision-preview',
|
|||
|
max_tokens: 1000,
|
|||
|
temperature: 1,
|
|||
|
top_p: 1,
|
|||
|
presence_penalty: 0,
|
|||
|
frequency_penalty: 0,
|
|||
|
messages: [{ role: 'user', content: [{ type: 'text', text }] }],
|
|||
|
stream: true,
|
|||
|
}
|
|||
|
|
|||
|
try {
|
|||
|
const resp = await fetch(baseUrl + '/chat/completion', {
|
|||
|
method: 'POST',
|
|||
|
headers: { 'Content-Type': 'application/json', Authorization: token },
|
|||
|
body: JSON.stringify(body),
|
|||
|
})
|
|||
|
const reader = resp.body!.getReader()
|
|||
|
const decoder = new TextDecoder()
|
|||
|
let buffer = ''
|
|||
|
let done = false
|
|||
|
|
|||
|
while (!done) {
|
|||
|
const { value, done: streamDone } = await reader.read()
|
|||
|
done = streamDone
|
|||
|
if (value) {
|
|||
|
buffer += decoder.decode(value, { stream: true })
|
|||
|
const parts = buffer.split('data: ')
|
|||
|
buffer = parts.pop()!
|
|||
|
for (const part of parts) {
|
|||
|
scrollToBottom()
|
|||
|
console.log('1')
|
|||
|
const chunk = part.trim()
|
|||
|
if (chunk === '[DONE]') {
|
|||
|
done = true
|
|||
|
break
|
|||
|
}
|
|||
|
try {
|
|||
|
const json = JSON.parse(chunk)
|
|||
|
const delta = json.choices?.[0]?.delta?.content
|
|||
|
if (delta) {
|
|||
|
aiMsg.content += delta
|
|||
|
scrollToBottom()
|
|||
|
console.log('2')
|
|||
|
}
|
|||
|
} catch {}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
scrollToBottom()
|
|||
|
} catch (err) {
|
|||
|
console.error(err)
|
|||
|
} finally {
|
|||
|
loading.value = false
|
|||
|
showActions.value = false
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
function copyText(msg: IMessage) {
|
|||
|
if (typeof msg.content === 'string') {
|
|||
|
navigator.clipboard.writeText(msg.content)
|
|||
|
alert('已复制')
|
|||
|
}
|
|||
|
}
|
|||
|
function refreshText(msg: IMessage) {
|
|||
|
if (typeof msg.content === 'string') {
|
|||
|
inputText.value = msg.content
|
|||
|
const idx = messages.indexOf(msg)
|
|||
|
messages.splice(idx, 1)
|
|||
|
sendText()
|
|||
|
}
|
|||
|
}
|
|||
|
</script>
|
|||
|
|
|||
|
<style lang="scss" scoped>
|
|||
|
.slide-up-enter-active,
|
|||
|
.slide-up-leave-active {
|
|||
|
transition: transform 0.3s ease-out;
|
|||
|
}
|
|||
|
.slide-up-enter-from,
|
|||
|
.slide-up-leave-to {
|
|||
|
transform: translateY(100%);
|
|||
|
}
|
|||
|
.slide-up-enter-to,
|
|||
|
.slide-up-leave-from {
|
|||
|
transform: translateY(0%);
|
|||
|
}
|
|||
|
.font-pf {
|
|||
|
font-family: PingFangSC-Medium, sans-serif;
|
|||
|
}
|
|||
|
</style>
|