feat: 上传文件
BIN
AIchat.rar
Normal file
10
env/.env
vendored
@ -3,12 +3,12 @@ VITE_APP_PORT = 9000
|
||||
|
||||
VITE_UNI_APPID = 'H57F2ACE4'
|
||||
VITE_WX_APPID = 'wxa2abb91f64032a2b'
|
||||
|
||||
VITE_DEV_TOKEN= "79b5c732d96d2b27a48a99dfd4a5566c43aaa5796242e854ebe3ffc198d6876b9628e7b764d9af65ab5dbb2d517ced88170491b74b048c0ba827c0d3741462cb89dc59ed46653a449af837a8262941ca1430937103230a1e32a1715f569f3efdbe6f8cb8b7b8642bd679668081b9b08f693d1b5be6002d936ec51e1e3e0c4927de9e32ac99a109b326e5d2bda27ec87624bb416ec70d2a95a2e190feeba9f0d6bae8571b3dfe89c824712344759a8f2bff9d70747c52525cf6a5614f9c770bca461a9b9c247b6dca97bcf83bbaf99bb726752c4fe1e9a4aa7de5c4cf3e88a3e480801280d45cdc124f9d8221105d852945dc6ce10bc1647e4f09dff4d52ffdfc5ce974441a21f37c4a81e3853b862975bd76099c6e2158cb3681ca6497d2c159c3b954852ace961bc334cdd547d0b2b441fbf51f2ef339bb58c27181206e20b1eb4cf26398e43bba65eba121ad88f20b"
|
||||
# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base
|
||||
VITE_APP_PUBLIC_BASE=/
|
||||
|
||||
VITE_SERVER_BASEURL = 'https://ukw0y1.laf.run'
|
||||
VITE_UPLOAD_BASEURL = 'https://ukw0y1.laf.run/upload'
|
||||
VITE_SERVER_BASEURL = 'http://114.218.158.24:9020'
|
||||
VITE_UPLOAD_BASEURL = 'http://114.218.158.24:9020'
|
||||
|
||||
# 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。
|
||||
# 下面的变量如果没有设置,会默认使用 VITE_SERVER_BASEURL or VITE_UPLOAD_BASEURL
|
||||
@ -21,5 +21,5 @@ VITE_UPLOAD_BASEURL__WEIXIN_TRIAL = 'https://ukw0y1.laf.run/upload'
|
||||
VITE_UPLOAD_BASEURL__WEIXIN_RELEASE = 'https://ukw0y1.laf.run/upload'
|
||||
|
||||
# h5是否需要配置代理
|
||||
VITE_APP_PROXY=false
|
||||
VITE_APP_PROXY_PREFIX = '/api'
|
||||
VITE_APP_PROXY=true
|
||||
VITE_APP_PROXY_PREFIX = '/upload'
|
||||
|
@ -102,6 +102,7 @@
|
||||
"@tanstack/vue-query": "^5.62.16",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"dayjs": "1.11.10",
|
||||
"element-plus": "^2.9.10",
|
||||
"pinia": "2.0.36",
|
||||
"pinia-plugin-persistedstate": "3.2.1",
|
||||
"qs": "6.5.3",
|
||||
|
@ -2,8 +2,8 @@ import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
|
||||
|
||||
export default defineUniPages({
|
||||
globalStyle: {
|
||||
navigationStyle: 'default',
|
||||
navigationBarTitleText: 'unibest',
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '',
|
||||
navigationBarBackgroundColor: '#f8f8f8',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#FFFFFF',
|
||||
@ -21,23 +21,10 @@ export default defineUniPages({
|
||||
selectedColor: '#018d71',
|
||||
backgroundColor: '#F8F8F8',
|
||||
borderStyle: 'black',
|
||||
height: '50px',
|
||||
fontSize: '10px',
|
||||
height: '0px',
|
||||
fontSize: '0px',
|
||||
iconWidth: '24px',
|
||||
spacing: '3px',
|
||||
list: [
|
||||
{
|
||||
iconPath: 'static/tabbar/home.png',
|
||||
selectedIconPath: 'static/tabbar/homeHL.png',
|
||||
pagePath: 'pages/index/index',
|
||||
text: '首页',
|
||||
},
|
||||
{
|
||||
iconPath: 'static/tabbar/example.png',
|
||||
selectedIconPath: 'static/tabbar/exampleHL.png',
|
||||
pagePath: 'pages/about/about',
|
||||
text: '关于',
|
||||
},
|
||||
],
|
||||
list: [],
|
||||
},
|
||||
})
|
||||
|
14564
pnpm-lock.yaml
@ -47,7 +47,7 @@ const httpInterceptor = {
|
||||
options.timeout = 10000 // 10s
|
||||
// 2. (可选)添加小程序端请求头标识
|
||||
options.header = {
|
||||
platform, // 可选,与 uniapp 定义的平台一致,告诉后台来源
|
||||
// platform, // 可选,与 uniapp 定义的平台一致,告诉后台来源
|
||||
...options.header,
|
||||
}
|
||||
// 3. 添加 token 请求头标识
|
||||
|
@ -2,6 +2,9 @@ import '@/style/index.scss'
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import 'virtual:uno.css'
|
||||
import { createSSRApp } from 'vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import { prototypeInterceptor, requestInterceptor, routeInterceptor } from './interceptors'
|
||||
@ -14,6 +17,9 @@ export function createApp() {
|
||||
app.use(requestInterceptor)
|
||||
app.use(prototypeInterceptor)
|
||||
app.use(VueQueryPlugin)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
return {
|
||||
app,
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"globalStyle": {
|
||||
"navigationStyle": "default",
|
||||
"navigationBarTitleText": "unibest",
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "",
|
||||
"navigationBarBackgroundColor": "#f8f8f8",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#FFFFFF"
|
||||
@ -18,32 +18,20 @@
|
||||
"selectedColor": "#018d71",
|
||||
"backgroundColor": "#F8F8F8",
|
||||
"borderStyle": "black",
|
||||
"height": "50px",
|
||||
"fontSize": "10px",
|
||||
"height": "0px",
|
||||
"fontSize": "0px",
|
||||
"iconWidth": "24px",
|
||||
"spacing": "3px",
|
||||
"list": [
|
||||
{
|
||||
"iconPath": "static/tabbar/home.png",
|
||||
"selectedIconPath": "static/tabbar/homeHL.png",
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "首页"
|
||||
},
|
||||
{
|
||||
"iconPath": "static/tabbar/example.png",
|
||||
"selectedIconPath": "static/tabbar/exampleHL.png",
|
||||
"pagePath": "pages/about/about",
|
||||
"text": "关于"
|
||||
}
|
||||
]
|
||||
"list": []
|
||||
},
|
||||
"__esModule": true,
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"type": "home",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "首页"
|
||||
"navigationBarHidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -52,6 +40,22 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "关于"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/index/index1",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationBarHidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/preview/index",
|
||||
"type": "page"
|
||||
},
|
||||
{
|
||||
"path": "pages/webview/index",
|
||||
"type": "page"
|
||||
}
|
||||
],
|
||||
"subPackages": []
|
||||
|
553
src/pages/index/index1.vue
Normal file
@ -0,0 +1,553 @@
|
||||
<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>
|
54
src/pages/preview/index.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<view class="image-gallery grid grid-cols-4 gap-1 p-4" v-if="imageList.length > 0">
|
||||
<view
|
||||
v-for="(image, index) in imageList"
|
||||
:key="index"
|
||||
class="aspect-square overflow-hidden group"
|
||||
>
|
||||
<image
|
||||
:src="image.url"
|
||||
mode="aspectFill"
|
||||
class="w-full h-full"
|
||||
@click="handleImageClick(index)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="videoList.length > 0" class="flex items-center justify-center">
|
||||
<view v-for="(video, index) in videoList" :key="index" class="w-full h-50">
|
||||
<video :src="video" class="w-full h-full" controls :poster="video.url"></video>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
// 图片列表数据
|
||||
const imageList = ref([])
|
||||
//视频列表
|
||||
const videoList = ref([])
|
||||
|
||||
// 图片点击处理
|
||||
const handleImageClick = (index) => {
|
||||
uni.previewImage({ urls: [imageList.value[index].url] })
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
//获取本地预览图片
|
||||
const previewImages = uni.getStorageSync('previewImages')
|
||||
//视频列表
|
||||
const previewVideos = uni.getStorageSync('previewVideos')
|
||||
|
||||
if (previewImages && previewImages.length > 0) {
|
||||
imageList.value = previewImages
|
||||
}
|
||||
|
||||
if (previewVideos && previewVideos.length > 0) {
|
||||
videoList.value = previewVideos
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
20
src/pages/webview/index.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<web-view :src="link"></web-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
link: '',
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
if (options && options.link) {
|
||||
this.link = options.link
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
@ -1,27 +1,157 @@
|
||||
import { http } from '@/utils/http'
|
||||
import { CustomRequestOptions } from '@/interceptors/request'
|
||||
import request from '@/utils/request'
|
||||
import * as API from '../app/types'
|
||||
|
||||
export interface IFooItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** GET 请求 */
|
||||
export const getFooAPI = (name: string) => {
|
||||
return http.get<IFooItem>('/foo', { name })
|
||||
export const getFooAPI = (path: string, name: string) => {
|
||||
return http.get<IFooItem>(path, { name })
|
||||
}
|
||||
/** GET 请求;支持 传递 header 的范例 */
|
||||
export const getFooAPI2 = (name: string) => {
|
||||
return http.get<IFooItem>('/foo', { name }, { 'Content-Type-100': '100' })
|
||||
export const getFooAPI2 = (path: string, name: string) => {
|
||||
return http.get<IFooItem>(path, { name }, { 'Content-Type-100': '100' })
|
||||
}
|
||||
|
||||
export interface IMessage {
|
||||
role: 'user' | 'system' | 'assistant'
|
||||
content: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: string }>
|
||||
}
|
||||
|
||||
export interface IGptRequestBody {
|
||||
model: string
|
||||
max_tokens: number
|
||||
temperature: number
|
||||
top_p: number
|
||||
presence_penalty: number
|
||||
frequency_penalty: number
|
||||
messages: IMessage[]
|
||||
stream: boolean
|
||||
}
|
||||
|
||||
// GPT 响应类型(可根据你接口返回结构细化)
|
||||
export interface IGptResponse {
|
||||
id: string
|
||||
object: string
|
||||
choices: any[]
|
||||
}
|
||||
|
||||
// 👇 支持传 path 和 body 的 post 函数
|
||||
export const postGptAPI = (path: string, body: IGptRequestBody, headers?: Record<string, any>) => {
|
||||
return http.post<IGptResponse>(path, body)
|
||||
}
|
||||
// export const postUpload = (path: string, form: FormData, query?: Record<string, any>, headers?: Record<string, any>) => {
|
||||
// return http.post<IFooItem>(path, form,query,headers)
|
||||
// }
|
||||
|
||||
// 1. 定义一个新的扁平化接口
|
||||
export type UploadMultiForm = {
|
||||
/** 文件字段,key 为 k1…k10,可传单个 File 或 File[] */
|
||||
[K in `k${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10}`]?: File | File[]
|
||||
} & {
|
||||
source: string // 画作来源
|
||||
mask: string // 画家 uid 或 用户 id
|
||||
type: string // 资源类型:image / file
|
||||
}
|
||||
|
||||
// 2. 改造 uploadMulti,让它直接把所有字段扁平化地 append 到 FormData
|
||||
export async function uploadMulti(form: UploadMultiForm, options?: CustomRequestOptions) {
|
||||
const formData = new FormData()
|
||||
|
||||
Object.entries(form).forEach(([key, val]) => {
|
||||
if (val instanceof File) {
|
||||
// 单个文件
|
||||
formData.append(key, val)
|
||||
} else if (Array.isArray(val)) {
|
||||
// 文件数组
|
||||
val.forEach((file) => formData.append(key, file))
|
||||
} else {
|
||||
// 普通文本字段
|
||||
formData.append(key, String(val))
|
||||
}
|
||||
})
|
||||
|
||||
return request<API.ApiResponse>('/upload/multi', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// H5/浏览器 会自动带 boundary
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
data: formData,
|
||||
...(options || {}),
|
||||
})
|
||||
}
|
||||
|
||||
// export interface UploadMultiBody {
|
||||
// source: string // 画作来源
|
||||
// mask: string // 画家 uid 或 用户 id
|
||||
// detail: string // 详情描述
|
||||
// type: string // 资源类型:image / file
|
||||
// }
|
||||
|
||||
// export interface UploadMultiParams {
|
||||
// files?: File[] // 最多取前 3 个文件,分别对应 k1、k2、k3
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * 上传多文件到 /upload/multi
|
||||
// */
|
||||
// export async function uploadMulti({
|
||||
// params,
|
||||
// body,
|
||||
// options,
|
||||
// }: {
|
||||
// /** 文件和文本字段 */
|
||||
// params: UploadMultiParams
|
||||
// body: UploadMultiBody
|
||||
// /** uni.request / uni.uploadFile 的可选配置 */
|
||||
// options?: CustomRequestOptions
|
||||
// }) {
|
||||
// // 1. 构造 FormData
|
||||
// const formData = new FormData()
|
||||
// ;(params.files || []).slice(0, 10).forEach((file, idx) => {
|
||||
// formData.append(`k${idx + 1}`, file)
|
||||
// })
|
||||
// formData.append('source', body.source)
|
||||
// formData.append('mask', body.mask)
|
||||
// formData.append('detail', body.detail)
|
||||
// formData.append('type', body.type)
|
||||
|
||||
// // 2. 发起请求
|
||||
// return request<API.ApiResponse>('/upload/multi', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// // 通常不需要手动写 Content-Type,浏览器/uniapp 会自动带 boundary
|
||||
// 'Content-Type': 'multipart/form-data',
|
||||
// },
|
||||
// data: formData,
|
||||
// ...(options || {}),
|
||||
// })
|
||||
// }
|
||||
|
||||
/** POST 请求 */
|
||||
export const postFooAPI = (name: string) => {
|
||||
return http.post<IFooItem>('/foo', { name })
|
||||
export const postFooAPI = (path: string, body: Record<string, any>) => {
|
||||
return http.post<IFooItem>(path, body)
|
||||
}
|
||||
/** POST 请求;需要传递 query 参数的范例;微信小程序经常有同时需要query参数和body参数的场景 */
|
||||
export const postFooAPI2 = (name: string) => {
|
||||
return http.post<IFooItem>('/foo', { name })
|
||||
export const postFooAPI2 = (
|
||||
path: string,
|
||||
body: Record<string, any>,
|
||||
query?: Record<string, any>,
|
||||
) => {
|
||||
return http.post<IFooItem>(path, body, query)
|
||||
}
|
||||
|
||||
/** POST 请求;支持 传递 header 的范例 */
|
||||
export const postFooAPI3 = (name: string) => {
|
||||
return http.post<IFooItem>('/foo', { name }, { name }, { 'Content-Type-100': '100' })
|
||||
export const postFooAPI3 = (
|
||||
path: string,
|
||||
body: Record<string, any>,
|
||||
query?: Record<string, any>,
|
||||
headers?: Record<string, any>,
|
||||
) => {
|
||||
return http.post<IFooItem>(path, body, query, headers)
|
||||
}
|
||||
|
BIN
src/static/aichat/Back.png
Normal file
After Width: | Height: | Size: 478 B |
BIN
src/static/aichat/add-circle.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
src/static/aichat/close.png
Normal file
After Width: | Height: | Size: 360 B |
BIN
src/static/aichat/copy.png
Normal file
After Width: | Height: | Size: 455 B |
BIN
src/static/aichat/empty.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
src/static/aichat/enter.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
src/static/aichat/files.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
src/static/aichat/logo-message.png
Normal file
After Width: | Height: | Size: 5.3 KiB |
BIN
src/static/aichat/logo.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
src/static/aichat/new.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
src/static/aichat/phone-img.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
src/static/aichat/photo.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
src/static/aichat/resect.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
src/static/aichat/shirk-cl.png
Normal file
After Width: | Height: | Size: 361 B |
BIN
src/static/aichat/shrink.png
Normal file
After Width: | Height: | Size: 458 B |
BIN
src/static/aichat/time.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
7
src/types/uni-pages.d.ts
vendored
@ -5,12 +5,15 @@
|
||||
|
||||
interface NavigateToOptions {
|
||||
url: "/pages/index/index" |
|
||||
"/pages/about/about";
|
||||
"/pages/about/about" |
|
||||
"/pages/index/index1" |
|
||||
"/pages/preview/index" |
|
||||
"/pages/webview/index";
|
||||
}
|
||||
interface RedirectToOptions extends NavigateToOptions {}
|
||||
|
||||
interface SwitchTabOptions {
|
||||
url: "/pages/index/index" | "/pages/about/about"
|
||||
|
||||
}
|
||||
|
||||
type ReLaunchOptions = NavigateToOptions | SwitchTabOptions;
|
||||
|
94
src/utils/guid.js
Normal file
@ -0,0 +1,94 @@
|
||||
function GUID() {
|
||||
this.date = new Date()
|
||||
|
||||
/* 判断是否初始化过,如果初始化过以下代码,则以下代码将不再执行,实际中只执行一次 */
|
||||
if (typeof this.newGUID != 'function') {
|
||||
/* 生成GUID码 */
|
||||
GUID.prototype.newGUID = function () {
|
||||
this.date = new Date()
|
||||
var guidStr = ''
|
||||
var sexadecimalDate = this.hexadecimal(this.getGUIDDate(), 16)
|
||||
var sexadecimalTime = this.hexadecimal(this.getGUIDTime(), 16)
|
||||
for (var i = 0; i < 9; i++) {
|
||||
guidStr += Math.floor(Math.random() * 16).toString(16)
|
||||
}
|
||||
guidStr += sexadecimalDate
|
||||
guidStr += sexadecimalTime
|
||||
while (guidStr.length < 32) {
|
||||
guidStr += Math.floor(Math.random() * 16).toString(16)
|
||||
}
|
||||
return this.formatGUID(guidStr)
|
||||
}
|
||||
|
||||
/*
|
||||
* 功能:获取当前日期的GUID格式,即8位数的日期:19700101
|
||||
* 返回值:返回GUID日期格式的字条串
|
||||
*/
|
||||
GUID.prototype.getGUIDDate = function () {
|
||||
return (
|
||||
this.date.getFullYear() +
|
||||
this.addZero(this.date.getMonth() + 1) +
|
||||
this.addZero(this.date.getDay())
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* 功能:获取当前时间的GUID格式,即8位数的时间,包括毫秒,毫秒为2位数:12300933
|
||||
* 返回值:返回GUID日期格式的字条串
|
||||
*/
|
||||
GUID.prototype.getGUIDTime = function () {
|
||||
return (
|
||||
this.addZero(this.date.getHours()) +
|
||||
this.addZero(this.date.getMinutes()) +
|
||||
this.addZero(this.date.getSeconds()) +
|
||||
this.addZero(parseInt(this.date.getMilliseconds() / 10))
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* 功能: 为一位数的正整数前面添加0,如果是可以转成非NaN数字的字符串也可以实现
|
||||
* 参数: 参数表示准备再前面添加0的数字或可以转换成数字的字符串
|
||||
* 返回值: 如果符合条件,返回添加0后的字条串类型,否则返回自身的字符串
|
||||
*/
|
||||
GUID.prototype.addZero = function (num) {
|
||||
if (Number(num).toString() != 'NaN' && num >= 0 && num < 10) {
|
||||
return '0' + Math.floor(num)
|
||||
} else {
|
||||
return num.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 功能:将y进制的数值,转换为x进制的数值
|
||||
* 参数:第1个参数表示欲转换的数值;第2个参数表示欲转换的进制;第3个参数可选,表示当前的进制数,如不写则为10
|
||||
* 返回值:返回转换后的字符串
|
||||
*/
|
||||
GUID.prototype.hexadecimal = function (num, x, y) {
|
||||
if (y != undefined) {
|
||||
return parseInt(num.toString(), y).toString(x)
|
||||
} else {
|
||||
return parseInt(num.toString()).toString(x)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 功能:格式化32位的字符串为GUID模式的字符串
|
||||
* 参数:第1个参数表示32位的字符串
|
||||
* 返回值:标准GUID格式的字符串
|
||||
*/
|
||||
GUID.prototype.formatGUID = function (guidStr) {
|
||||
var str1 = guidStr.slice(0, 8),
|
||||
str2 = guidStr.slice(8, 12),
|
||||
str3 = guidStr.slice(12, 16),
|
||||
str4 = guidStr.slice(16, 20),
|
||||
str5 = guidStr.slice(20)
|
||||
return str1 + str2 + str3 + str4 + str5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getGuid: function () {
|
||||
return new GUID().newGUID()
|
||||
},
|
||||
}
|