oa_app仓库初始化
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<view class="page call">
|
||||
<view class="stage">
|
||||
<web-view v-if="roomSrc" :src="roomSrc" class="wv" />
|
||||
<view v-else class="wait">
|
||||
<view class="av">{{ initial }}</view>
|
||||
<text class="name">{{ peerName }}</text>
|
||||
<text class="st">{{ statusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="acts">
|
||||
<view v-if="ringing" class="btn ok" @click="accept">接听</view>
|
||||
<view class="btn no" @click="hang">挂断</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { store } from '../../utils/store.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return { off: null, tick: 0 }
|
||||
},
|
||||
computed: {
|
||||
call() {
|
||||
this.tick
|
||||
return store.call
|
||||
},
|
||||
ringing() {
|
||||
return this.call && this.call.status === 'ringing'
|
||||
},
|
||||
peerName() {
|
||||
const c = this.call
|
||||
if (!c) return '通话'
|
||||
const p = store.person(c.peerId)
|
||||
return (p && p.name) || '同事'
|
||||
},
|
||||
initial() {
|
||||
return this.peerName.slice(0, 1)
|
||||
},
|
||||
statusText() {
|
||||
const s = this.call && this.call.status
|
||||
if (s === 'calling') return '正在呼叫…'
|
||||
if (s === 'ringing') return '邀请你通话'
|
||||
if (s === 'active') return '通话中'
|
||||
return '连接中'
|
||||
},
|
||||
roomSrc() {
|
||||
const c = this.call
|
||||
if (!c || !c.token || !c.url) return ''
|
||||
if (c.status !== 'active' && c.direction !== 'out') return ''
|
||||
return 'https://meet.livekit.io/custom/?liveKitUrl=' + encodeURIComponent(c.url) + '&token=' + encodeURIComponent(c.token)
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
if (this.off) this.off()
|
||||
this.off = store.on(() => {
|
||||
this.tick += 1
|
||||
if (!store.call) {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) uni.navigateBack()
|
||||
}
|
||||
})
|
||||
if (!store.call) uni.navigateBack()
|
||||
},
|
||||
onHide() {
|
||||
if (this.off) this.off()
|
||||
this.off = null
|
||||
},
|
||||
onUnload() {
|
||||
if (this.off) this.off()
|
||||
if (store.call) store.endCall(true)
|
||||
},
|
||||
methods: {
|
||||
async accept() {
|
||||
try {
|
||||
uni.showLoading({ title: '接通中…', mask: true })
|
||||
await store.acceptCall()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: (e && e.message) || '接听失败', icon: 'none' })
|
||||
}
|
||||
try { uni.hideLoading() } catch (_) {}
|
||||
},
|
||||
hang() {
|
||||
store.endCall(true)
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.call { height: 100vh; background: #0f172a; color: #fff; display: flex; flex-direction: column; }
|
||||
.stage { flex: 1; position: relative; }
|
||||
.wv { position: absolute; inset: 0; }
|
||||
.wait { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.av { width: 160rpx; height: 160rpx; border-radius: 50%; background: #2563eb; display: flex; align-items: center; justify-content: center; font-size: 64rpx; font-weight: 700; }
|
||||
.name { margin-top: 24rpx; font-size: 36rpx; font-weight: 700; }
|
||||
.st { margin-top: 8rpx; color: rgba(255,255,255,0.65); }
|
||||
.acts { display: flex; justify-content: center; gap: 32rpx; padding: 32rpx 24rpx calc(32rpx + env(safe-area-inset-bottom)); }
|
||||
.btn { min-width: 200rpx; height: 80rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 600; }
|
||||
.ok { background: #006c49; }
|
||||
.no { background: #ba1a1a; }
|
||||
</style>
|
||||
@@ -0,0 +1,799 @@
|
||||
<template>
|
||||
<view class="page chat">
|
||||
<fy-header
|
||||
:title="barTitle"
|
||||
:back="!selecting"
|
||||
:left-label="selecting ? '取消' : ''"
|
||||
@left="exitSelect"
|
||||
>
|
||||
<template #right>
|
||||
<text v-if="!selecting && isGroup" class="ico" @click="goMore">···</text>
|
||||
<text v-else-if="!selecting && peerId" class="ico" @click="goMore">···</text>
|
||||
</template>
|
||||
</fy-header>
|
||||
|
||||
<scroll-view
|
||||
class="msgs"
|
||||
:scroll-y="!recording"
|
||||
:scroll-into-view="tail"
|
||||
scroll-with-animation
|
||||
:refresher-enabled="!recording"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="reload"
|
||||
@click="onMsgsClick"
|
||||
>
|
||||
<view v-if="!list.length" class="empty">暂无消息,发一条试试</view>
|
||||
<view v-for="(m, i) in list" :key="m.id || i">
|
||||
<view v-if="showTime(i)" class="chip-time">{{ timeOf(m) }}</view>
|
||||
<view v-if="m.recalled" class="sys">{{ m.text || '已撤回一条消息' }}</view>
|
||||
<view
|
||||
v-else
|
||||
class="row"
|
||||
:class="{ mine: m.mine, pick: selecting }"
|
||||
@click="selecting ? togglePick(m) : tapMsg(m)"
|
||||
@longpress="act(m)"
|
||||
>
|
||||
<view v-if="selecting" class="ck" :class="{ on: isPicked(m) }" />
|
||||
<view v-if="!m.mine" class="av" @click.stop="openSender(m)">
|
||||
<image v-if="avatarOf(m)" :src="avatarOf(m)" mode="aspectFill" />
|
||||
<text v-else>{{ initialOf(m) }}</text>
|
||||
</view>
|
||||
<view class="col" :class="{ mine: m.mine }">
|
||||
<text v-if="isGroup && !m.mine" class="who">{{ sender(m) }}</text>
|
||||
<view class="ball" :class="ballClass(m)">
|
||||
<view v-if="m.quote" class="quote">{{ quoteLine(m.quote) }}</view>
|
||||
<image v-if="m.kind === 'image' && m.fileUrl" class="pic" :src="m.fileUrl" mode="widthFix" />
|
||||
<view v-else-if="m.kind === 'voice'" class="voice" :style="{ width: voiceW(m) }">
|
||||
<text>{{ m.mine ? '' : '♪' }} {{ voiceLen(m) }}″ {{ m.mine ? '♪' : '' }}</text>
|
||||
</view>
|
||||
<view v-else-if="m.kind === 'file'" class="file">
|
||||
<text class="fn">{{ m.fileName || m.text || '文件' }}</text>
|
||||
<text class="fs">点击打开</text>
|
||||
</view>
|
||||
<view v-else-if="isWorkCard(m)" class="cardtxt">
|
||||
<text class="ct">{{ m.title || m.text || '工作卡片' }}</text>
|
||||
<text class="cs">点击查看详情</text>
|
||||
</view>
|
||||
<text v-else>{{ m.text }}</text>
|
||||
</view>
|
||||
<text v-if="m.mine" class="tm" :class="{ read: m.read || readLabel(m) === '已读' }" @click="showRead(m)">{{ readLabel(m) }}</text>
|
||||
</view>
|
||||
<view v-if="m.mine" class="av self">
|
||||
<image v-if="myAvatar" :src="myAvatar" mode="aspectFill" />
|
||||
<text v-else>{{ myInitial }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view id="tail" />
|
||||
<view id="end" />
|
||||
</scroll-view>
|
||||
|
||||
<view
|
||||
v-if="recording"
|
||||
class="recmask"
|
||||
@touchend="holdEnd"
|
||||
@touchcancel="holdEnd"
|
||||
>
|
||||
<view class="recbox" :class="{ cancel: recWillCancel }">
|
||||
<text class="recsec">{{ recSec }}″</text>
|
||||
<text>{{ recWillCancel ? '松开取消' : '松开发送,上滑取消' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="actionMsg && !selecting" class="mask" @click="closeAct">
|
||||
<view class="sheet" :style="{ paddingBottom: (safeBottom + 12) + 'px' }" @click.stop>
|
||||
<text class="sheet-t">消息操作</text>
|
||||
<text class="sheet-s">{{ actPreview }}</text>
|
||||
<view class="sheet-grid">
|
||||
<view v-if="actionMsg.kind === 'voice'" class="cell" @click="doPlay"><text class="ce">▶</text><text>播放</text></view>
|
||||
<view v-if="actionMsg.kind !== 'voice'" class="cell" @click="doCopy"><text class="ce">文</text><text>复制</text></view>
|
||||
<view class="cell" @click="doForward"><text class="ce">↪</text><text>转发</text></view>
|
||||
<view class="cell" @click="doQuote"><text class="ce">❝</text><text>引用</text></view>
|
||||
<view class="cell" @click="enterSelect"><text class="ce">多</text><text>多选</text></view>
|
||||
<view v-if="canRecall" class="cell" @click="doRecall"><text class="ce">↩</text><text>撤回</text></view>
|
||||
<view class="cell" @click="doDelete"><text class="ce">删</text><text>删除</text></view>
|
||||
</view>
|
||||
<view class="sheet-cancel" @click="closeAct">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="selecting" class="selbar" :style="{ paddingBottom: safeBottom + 'px' }">
|
||||
<view class="selbtn" @click="forwardPicked"><text>转发</text></view>
|
||||
<view class="selbtn" @click="mergePicked"><text>合并转发</text></view>
|
||||
<view class="selbtn danger" @click="deletePicked"><text>删除</text></view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!isWork" class="composer" :style="{ paddingBottom: composerPad }">
|
||||
<view v-if="quoting" class="qbar">
|
||||
<text class="qt">{{ quoteLine(quoting) }}</text>
|
||||
<text class="qx" @click="quoting = null">×</text>
|
||||
</view>
|
||||
<view class="bar">
|
||||
<text class="tool" @click="toggleVoice">{{ voiceMode ? '⌨' : '🎤' }}</text>
|
||||
<view
|
||||
v-if="voiceMode"
|
||||
class="hold"
|
||||
@touchstart="holdStart"
|
||||
@touchend="holdEnd"
|
||||
@touchcancel="holdEnd"
|
||||
>
|
||||
{{ recording ? (recWillCancel ? '松开取消' : '松开发送') : '按住 说话' }}
|
||||
</view>
|
||||
<view v-else class="box">
|
||||
<input
|
||||
v-model="draft"
|
||||
confirm-type="send"
|
||||
hold-keyboard
|
||||
:adjust-position="false"
|
||||
cursor-spacing="24"
|
||||
@confirm="send"
|
||||
@focus="onFocus"
|
||||
:placeholder="isGroup ? '输入消息,@ 可提醒成员…' : ' '"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="isGroup && !voiceMode" class="tool" @click="mention">@</text>
|
||||
<text class="tool" @click="toggleEmoji">☺</text>
|
||||
<text v-if="draft.trim()" class="go" @click="send">发送</text>
|
||||
<text v-else class="tool plus" @click="toggleMore">{{ more ? '×' : '+' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="emoji && !isWork && !selecting" class="panel emo" :style="{ paddingBottom: safeBottom + 'px' }">
|
||||
<text v-for="e in emos" :key="e" class="em" @click="insertEmoji(e)">{{ e }}</text>
|
||||
</view>
|
||||
<view v-else-if="more && !isWork && !selecting" class="panel" :style="{ paddingBottom: safeBottom + 'px' }">
|
||||
<view class="tile" @click="pick('album')"><text class="te">🖼</text><text>相册</text></view>
|
||||
<view class="tile" @click="pick('camera')"><text class="te">📷</text><text>拍摄</text></view>
|
||||
<view class="tile" @click="pick('file')"><text class="te">📎</text><text>文件</text></view>
|
||||
<view v-if="!isGroup" class="tile" @click="startCall(false)"><text class="te">☎</text><text>语音通话</text></view>
|
||||
<view v-if="!isGroup" class="tile" @click="startCall(true)"><text class="te">▷</text><text>视频通话</text></view>
|
||||
<view v-if="isGroup" class="tile" @click="mention"><text class="te">@</text><text>提醒成员</text></view>
|
||||
</view>
|
||||
<view v-else-if="isWork" class="hint">工作通知为办件卡片,不支持快捷回复</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FyHeader from '../../components/fy-header.vue'
|
||||
import { store, previewOf } from '../../utils/store.js'
|
||||
import { fmtTime, toast } from '../../utils/format.js'
|
||||
import { uploadLocal, fileTicket, openFile } from '../../utils/files.js'
|
||||
import { mobileUrl } from '../../utils/nav.js'
|
||||
|
||||
const EMOS = ['😀','😁','😂','😅','😊','😍','😘','😜','🤗','🤔','🙄','😴','😭','😡','👍','👎','👌','🙏','👏','💪','🔥','⭐','🎉','❤️','💕','✅','❌','💯','🤝','👀']
|
||||
|
||||
export default {
|
||||
components: { FyHeader },
|
||||
data() {
|
||||
return {
|
||||
sid: '',
|
||||
draft: '',
|
||||
off: null,
|
||||
tail: '',
|
||||
list: [],
|
||||
title: '聊天',
|
||||
isGroup: false,
|
||||
isWork: false,
|
||||
peerId: '',
|
||||
myAvatar: '',
|
||||
myInitial: '我',
|
||||
memberCount: 0,
|
||||
kb: 0,
|
||||
more: false,
|
||||
emoji: false,
|
||||
voiceMode: false,
|
||||
refreshing: false,
|
||||
safeBottom: 0,
|
||||
recording: false,
|
||||
recWillCancel: false,
|
||||
recSec: 0,
|
||||
actionMsg: null,
|
||||
quoting: null,
|
||||
selecting: false,
|
||||
picked: [],
|
||||
emos: EMOS,
|
||||
_recAt: 0,
|
||||
_recY: 0,
|
||||
_recTimer: 0,
|
||||
_rec: null,
|
||||
_recSkip: false,
|
||||
_playing: null,
|
||||
_kb: null,
|
||||
_kbBound: false,
|
||||
_lastId: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
barTitle() {
|
||||
if (this.selecting) return '已选择 ' + this.picked.length + ' 条消息'
|
||||
if (this.isWork) return '工作通知'
|
||||
if (this.isGroup) return this.title + (this.memberCount ? '(' + this.memberCount + ')' : '')
|
||||
return this.title
|
||||
},
|
||||
composerPad() {
|
||||
if (this.kb > 0) return this.kb + 'px'
|
||||
return (10 + this.safeBottom) + 'px'
|
||||
},
|
||||
canRecall() {
|
||||
const m = this.actionMsg
|
||||
return !!(m && m.mine && !m.recalled && Date.now() - Number(m.time) < 120000)
|
||||
},
|
||||
actPreview() {
|
||||
return previewOf(this.actionMsg)
|
||||
}
|
||||
},
|
||||
onLoad(q) {
|
||||
this.sid = decodeURIComponent(q.sid || '')
|
||||
try {
|
||||
this.safeBottom = Number(uni.getSystemInfoSync().safeAreaInsets && uni.getSystemInfoSync().safeAreaInsets.bottom) || 0
|
||||
} catch (_) {}
|
||||
this._rec = uni.getRecorderManager()
|
||||
this._rec.onStop((res) => this.afterRecord(res))
|
||||
this._rec.onError(() => {
|
||||
this.recording = false
|
||||
toast('录音失败,请检查麦克风权限')
|
||||
})
|
||||
},
|
||||
onShow() {
|
||||
this.bindKb()
|
||||
if (this.off) this.off()
|
||||
store.refreshPeoplePhotos()
|
||||
this.pull()
|
||||
this.off = store.on(() => this.pull())
|
||||
const c = store.findConv(this.sid)
|
||||
if (c) store.openChat(c)
|
||||
},
|
||||
onHide() {
|
||||
this.unbind()
|
||||
this.recCancel()
|
||||
this.actionMsg = null
|
||||
if (store.activeSid === this.sid) store.setActiveChat('')
|
||||
},
|
||||
onUnload() {
|
||||
this.unbind()
|
||||
this.unbindKb()
|
||||
this.recCancel()
|
||||
this.unbindRecDoc()
|
||||
if (store.activeSid === this.sid) store.setActiveChat('')
|
||||
if (this._playing) {
|
||||
try { this._playing.stop() } catch (_) {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
unbind() {
|
||||
if (this.off) this.off()
|
||||
this.off = null
|
||||
},
|
||||
bindKb() {
|
||||
if (this._kbBound) return
|
||||
this._kbBound = true
|
||||
this._kb = (res) => {
|
||||
this.kb = Number(res && res.height) || 0
|
||||
if (this.kb > 0) {
|
||||
this.more = false
|
||||
this.emoji = false
|
||||
}
|
||||
this.scrollEnd()
|
||||
}
|
||||
uni.onKeyboardHeightChange(this._kb)
|
||||
},
|
||||
unbindKb() {
|
||||
if (this._kb) {
|
||||
try { uni.offKeyboardHeightChange(this._kb) } catch (_) {}
|
||||
}
|
||||
this._kb = null
|
||||
this._kbBound = false
|
||||
},
|
||||
pull() {
|
||||
const c = store.findConv(this.sid)
|
||||
this.title = (c && c.name) || '聊天'
|
||||
this.isGroup = !!(c && c.type === 'group')
|
||||
this.isWork = !!(c && c.type === 'work')
|
||||
this.peerId = (c && c.peerId) || ''
|
||||
this.memberCount = c && c.memberIds ? c.memberIds.length : 0
|
||||
this.myAvatar = (store.me && store.me.avatar) || ''
|
||||
this.myInitial = ((store.me && store.me.name) || '我').slice(0, 1)
|
||||
const next = ((c && c.messages) || []).map((m) => ({ ...m }))
|
||||
const lastId = next.length ? next[next.length - 1].id : ''
|
||||
this.list = next
|
||||
if (lastId !== this._lastId) {
|
||||
this._lastId = lastId
|
||||
this.scrollEnd()
|
||||
}
|
||||
},
|
||||
scrollEnd() {
|
||||
this.$nextTick(() => {
|
||||
this.tail = this.tail === 'end' ? 'tail' : 'end'
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
this.refreshing = true
|
||||
const c = store.findConv(this.sid)
|
||||
try { if (c) await store.openChat(c) } finally { this.refreshing = false }
|
||||
},
|
||||
showTime(i) {
|
||||
if (i === 0) return true
|
||||
return Math.abs(Number(this.list[i].time) - Number(this.list[i - 1].time)) > 5 * 60 * 1000
|
||||
},
|
||||
timeOf(m) { return fmtTime(m.time) },
|
||||
voiceLen(m) { return Math.max(1, Number(m.duration) || 1) },
|
||||
voiceW(m) { return Math.min(360, 120 + this.voiceLen(m) * 12) + 'rpx' },
|
||||
readLabel(m) {
|
||||
if (!m || !m.mine || m.recalled) return ''
|
||||
if (this.isGroup && m.readReceipt) {
|
||||
const r = (m.readReceipt.read || []).length
|
||||
const u = (m.readReceipt.unread || []).length
|
||||
return '已读 ' + r + '/' + (r + u)
|
||||
}
|
||||
return m.read ? '已读' : '未读'
|
||||
},
|
||||
showRead(m) {
|
||||
if (!this.isGroup || !m || !m.readReceipt) return
|
||||
const r = (m.readReceipt.read || []).map((p) => p.name || p).join('、') || '暂无'
|
||||
const u = (m.readReceipt.unread || []).map((p) => p.name || p).join('、') || '暂无'
|
||||
uni.showModal({
|
||||
title: '已读详情',
|
||||
content: '已读:' + r + '\n未读:' + u,
|
||||
showCancel: false
|
||||
})
|
||||
},
|
||||
sender(m) {
|
||||
if (m.mine) return (store.me && store.me.name) || '我'
|
||||
const p = store.person(m.fromId)
|
||||
return (p && p.name) || this.title || '同事'
|
||||
},
|
||||
avatarOf(m) {
|
||||
if (m.mine) return this.myAvatar
|
||||
const p = store.person(m.fromId)
|
||||
if (p && p.avatar) return p.avatar
|
||||
const c = store.findConv(this.sid)
|
||||
return (c && c.avatar) || ''
|
||||
},
|
||||
initialOf(m) { return (this.sender(m) || '?').slice(0, 1) },
|
||||
isWorkCard(m) { return m.kind === 'card' || (this.isWork && !m.mine) },
|
||||
quoteLine(q) {
|
||||
if (!q) return ''
|
||||
const name = q.name || this.sender(q) || ''
|
||||
return (name ? name + ':' : '') + (q.text || previewOf(q))
|
||||
},
|
||||
ballClass(m) {
|
||||
return {
|
||||
self: m.mine && !m.recalled,
|
||||
work: this.isWorkCard(m),
|
||||
voice: m.kind === 'voice'
|
||||
}
|
||||
},
|
||||
onMsgsClick() {
|
||||
if (this.more || this.emoji) {
|
||||
this.more = false
|
||||
this.emoji = false
|
||||
}
|
||||
},
|
||||
onFocus() {
|
||||
this.more = false
|
||||
this.emoji = false
|
||||
this.voiceMode = false
|
||||
},
|
||||
hideKb() {
|
||||
try { uni.hideKeyboard() } catch (_) {}
|
||||
this.kb = 0
|
||||
},
|
||||
toggleMore() {
|
||||
this.emoji = false
|
||||
this.more = !this.more
|
||||
if (this.more) this.hideKb()
|
||||
},
|
||||
toggleEmoji() {
|
||||
this.more = false
|
||||
this.emoji = !this.emoji
|
||||
if (this.emoji) this.hideKb()
|
||||
},
|
||||
toggleVoice() {
|
||||
this.voiceMode = !this.voiceMode
|
||||
this.more = false
|
||||
this.emoji = false
|
||||
this.hideKb()
|
||||
},
|
||||
insertEmoji(e) {
|
||||
this.draft = (this.draft || '') + (e || '🙂')
|
||||
},
|
||||
send() {
|
||||
const c = store.findConv(this.sid)
|
||||
const text = String(this.draft || '').trim()
|
||||
if (!c || this.isWork || !text) return
|
||||
this.draft = ''
|
||||
store.sendText(c, text, this.quoting ? { quote: { ...this.quoting, name: this.quoting.name || this.sender(this.quoting) } } : null)
|
||||
this.quoting = null
|
||||
},
|
||||
holdStart(e) {
|
||||
if (this.isWork || this.recording) return
|
||||
const t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || {}
|
||||
this._recY = Number(t.clientY || t.pageY || 0)
|
||||
this.recWillCancel = false
|
||||
this._recSkip = false
|
||||
this.recording = true
|
||||
this.recSec = 0
|
||||
this._recAt = Date.now()
|
||||
if (this._recTimer) clearInterval(this._recTimer)
|
||||
this._recTimer = setInterval(() => {
|
||||
this.recSec = Math.floor((Date.now() - this._recAt) / 1000)
|
||||
}, 300)
|
||||
this.bindRecDoc()
|
||||
try {
|
||||
this._rec.start({ format: 'mp3', duration: 60000, sampleRate: 16000 })
|
||||
} catch (_) {
|
||||
this.unbindRecDoc()
|
||||
this.recording = false
|
||||
toast('无法开始录音')
|
||||
}
|
||||
},
|
||||
holdMove(e) {
|
||||
if (!this.recording) return
|
||||
const t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || {}
|
||||
const y = Number(t.clientY || t.pageY || 0)
|
||||
this.recWillCancel = !!(this._recY && y && this._recY - y > 70)
|
||||
},
|
||||
holdEnd() {
|
||||
if (!this.recording) return
|
||||
this.unbindRecDoc()
|
||||
if (this.recWillCancel) {
|
||||
this.recCancel()
|
||||
toast('已取消')
|
||||
return
|
||||
}
|
||||
this.recording = false
|
||||
this.recWillCancel = false
|
||||
if (this._recTimer) clearInterval(this._recTimer)
|
||||
try { this._rec.stop() } catch (_) {}
|
||||
},
|
||||
bindRecDoc() {
|
||||
this.unbindRecDoc()
|
||||
try {
|
||||
if (typeof document === 'undefined') return
|
||||
this._onRecMove = (ev) => this.holdMove(ev)
|
||||
this._onRecEnd = () => this.holdEnd()
|
||||
document.addEventListener('touchmove', this._onRecMove, { passive: true, capture: true })
|
||||
document.addEventListener('touchend', this._onRecEnd, { passive: true, capture: true })
|
||||
document.addEventListener('touchcancel', this._onRecEnd, { passive: true, capture: true })
|
||||
} catch (_) {}
|
||||
},
|
||||
unbindRecDoc() {
|
||||
try {
|
||||
if (typeof document === 'undefined') return
|
||||
if (this._onRecMove) document.removeEventListener('touchmove', this._onRecMove, true)
|
||||
if (this._onRecEnd) {
|
||||
document.removeEventListener('touchend', this._onRecEnd, true)
|
||||
document.removeEventListener('touchcancel', this._onRecEnd, true)
|
||||
}
|
||||
} catch (_) {}
|
||||
this._onRecMove = null
|
||||
this._onRecEnd = null
|
||||
},
|
||||
recCancel() {
|
||||
this.unbindRecDoc()
|
||||
this._recSkip = true
|
||||
this.recording = false
|
||||
this.recWillCancel = false
|
||||
if (this._recTimer) clearInterval(this._recTimer)
|
||||
try { if (this._rec) this._rec.stop() } catch (_) {}
|
||||
},
|
||||
async afterRecord(res) {
|
||||
if (this._recSkip) {
|
||||
this._recSkip = false
|
||||
return
|
||||
}
|
||||
const path = res && (res.tempFilePath || res.tempFile)
|
||||
const ms = Date.now() - this._recAt
|
||||
if (!path || ms < 600) {
|
||||
toast('说话时间太短')
|
||||
return
|
||||
}
|
||||
const conv = store.findConv(this.sid)
|
||||
if (!conv) return
|
||||
try {
|
||||
uni.showLoading({ title: '发送语音…', mask: true })
|
||||
const f = await uploadLocal(path, store.token, { kind: 'im', name: 'voice.mp3' })
|
||||
let url = ''
|
||||
try { url = (await fileTicket(f.id, store.token)).url } catch (_) {}
|
||||
store.sendVoice(conv, { ...f, url, duration: Math.round(ms / 1000) })
|
||||
} catch (e) {
|
||||
toast((e && e.message) || '语音发送失败')
|
||||
}
|
||||
try { uni.hideLoading() } catch (_) {}
|
||||
},
|
||||
playVoice(m) {
|
||||
if (!m.fileUrl) {
|
||||
toast('语音无法播放')
|
||||
return
|
||||
}
|
||||
if (this._playing) {
|
||||
try { this._playing.stop() } catch (_) {}
|
||||
this._playing = null
|
||||
}
|
||||
const audio = uni.createInnerAudioContext()
|
||||
audio.src = m.fileUrl
|
||||
audio.autoplay = true
|
||||
audio.onEnded(() => { try { audio.destroy() } catch (_) {} })
|
||||
this._playing = audio
|
||||
},
|
||||
pick(kind) {
|
||||
this.more = false
|
||||
const conv = store.findConv(this.sid)
|
||||
if (!conv) return
|
||||
const sendPaths = async (paths, names) => {
|
||||
if (!paths.length) return
|
||||
uni.showLoading({ title: '发送中…', mask: true })
|
||||
try {
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const f = await uploadLocal(paths[i], store.token, { kind: 'im', name: names[i] })
|
||||
let url = ''
|
||||
try { url = (await fileTicket(f.id, store.token)).url } catch (_) {}
|
||||
store.sendMedia(conv, { ...f, url })
|
||||
}
|
||||
} catch (e) {
|
||||
toast((e && e.message) || '发送失败')
|
||||
}
|
||||
try { uni.hideLoading() } catch (_) {}
|
||||
}
|
||||
if (kind === 'file') {
|
||||
const chooser = typeof uni.chooseFile === 'function' ? uni.chooseFile : null
|
||||
if (!chooser) return this.pick('album')
|
||||
chooser({
|
||||
count: 6,
|
||||
type: 'all',
|
||||
success(res) {
|
||||
const files = res.tempFiles || []
|
||||
sendPaths(files.map((f) => f.path || f.tempFilePath).filter(Boolean), files.map((f) => f.name || '文件'))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.chooseImage({
|
||||
count: kind === 'camera' ? 1 : 9,
|
||||
sizeType: ['compressed', 'original'],
|
||||
sourceType: kind === 'camera' ? ['camera'] : ['album'],
|
||||
success(res) {
|
||||
const paths = res.tempFilePaths || []
|
||||
const files = res.tempFiles || []
|
||||
sendPaths(paths, files.map((f, i) => f.name || ('图片' + (i + 1))))
|
||||
}
|
||||
})
|
||||
},
|
||||
act(m) {
|
||||
if (!m || m.recalled || this.selecting) return
|
||||
this.more = false
|
||||
this.emoji = false
|
||||
this.hideKb()
|
||||
this.actionMsg = m
|
||||
},
|
||||
closeAct() { this.actionMsg = null },
|
||||
liveMsg() {
|
||||
const c = store.findConv(this.sid)
|
||||
const m = this.actionMsg
|
||||
if (!c || !m) return { c: null, live: null }
|
||||
const live = (c.messages || []).find((x) => x.id === m.id) || m
|
||||
return { c, live }
|
||||
},
|
||||
doCopy() {
|
||||
const m = this.actionMsg
|
||||
if (!m) return
|
||||
uni.setClipboardData({ data: m.text || m.fileUrl || m.fileName || '' })
|
||||
this.closeAct()
|
||||
},
|
||||
doPlay() {
|
||||
if (this.actionMsg) this.playVoice(this.actionMsg)
|
||||
this.closeAct()
|
||||
},
|
||||
doForward() {
|
||||
const m = this.actionMsg
|
||||
if (!m) return
|
||||
store.pendingForward = { fromSid: this.sid, msgs: [{ ...m }], merge: false }
|
||||
this.closeAct()
|
||||
uni.navigateTo({ url: '/pages/chat/forward' })
|
||||
},
|
||||
doQuote() {
|
||||
const m = this.actionMsg
|
||||
if (!m) return
|
||||
this.quoting = { ...m, name: this.sender(m), text: m.kind === 'text' ? (m.text || '') : previewOf(m) }
|
||||
this.voiceMode = false
|
||||
this.closeAct()
|
||||
},
|
||||
doRecall() {
|
||||
const { c, live } = this.liveMsg()
|
||||
if (c && live) store.recall(c, live)
|
||||
this.closeAct()
|
||||
},
|
||||
doDelete() {
|
||||
const { c, live } = this.liveMsg()
|
||||
if (c && live) store.removeMessage(c, live)
|
||||
this.closeAct()
|
||||
},
|
||||
enterSelect() {
|
||||
const m = this.actionMsg
|
||||
this.selecting = true
|
||||
this.picked = m && m.id ? [m.id] : []
|
||||
this.closeAct()
|
||||
},
|
||||
exitSelect() {
|
||||
this.selecting = false
|
||||
this.picked = []
|
||||
},
|
||||
isPicked(m) { return this.picked.indexOf(m.id) >= 0 },
|
||||
togglePick(m) {
|
||||
if (!m || m.recalled) return
|
||||
const i = this.picked.indexOf(m.id)
|
||||
if (i >= 0) this.picked.splice(i, 1)
|
||||
else this.picked.push(m.id)
|
||||
},
|
||||
pickedMsgs() {
|
||||
const set = new Set(this.picked)
|
||||
return this.list.filter((m) => set.has(m.id) && !m.recalled)
|
||||
},
|
||||
forwardPicked() {
|
||||
const msgs = this.pickedMsgs()
|
||||
if (!msgs.length) return toast('请先选择消息')
|
||||
store.pendingForward = { fromSid: this.sid, msgs: msgs.map((m) => ({ ...m })), merge: false }
|
||||
this.exitSelect()
|
||||
uni.navigateTo({ url: '/pages/chat/forward' })
|
||||
},
|
||||
mergePicked() {
|
||||
const msgs = this.pickedMsgs()
|
||||
if (!msgs.length) return toast('请先选择消息')
|
||||
store.pendingForward = { fromSid: this.sid, msgs: msgs.map((m) => ({ ...m })), merge: true }
|
||||
this.exitSelect()
|
||||
uni.navigateTo({ url: '/pages/chat/forward' })
|
||||
},
|
||||
deletePicked() {
|
||||
const c = store.findConv(this.sid)
|
||||
const msgs = this.pickedMsgs()
|
||||
if (!c || !msgs.length) return
|
||||
msgs.forEach((m) => store.removeMessage(c, m))
|
||||
this.exitSelect()
|
||||
},
|
||||
tapMsg(m) {
|
||||
if (m.recalled) return
|
||||
if (m.kind === 'voice') return this.playVoice(m)
|
||||
if (m.kind === 'image' && m.fileUrl) {
|
||||
uni.previewImage({ urls: [m.fileUrl], current: m.fileUrl })
|
||||
return
|
||||
}
|
||||
if (m.kind === 'file') {
|
||||
openFile({ id: m.fileId || '', name: m.fileName || m.text, contentType: '' }, store.token)
|
||||
return
|
||||
}
|
||||
if (this.isWorkCard(m)) {
|
||||
if (m.href) {
|
||||
const url = mobileUrl(m.href, { no: m.no, id: m.taskId })
|
||||
if (url) uni.navigateTo({ url })
|
||||
return
|
||||
}
|
||||
if (m.taskId) uni.navigateTo({ url: '/pages/coop/detail?id=' + encodeURIComponent(m.taskId) })
|
||||
else if (m.no) uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(m.no) })
|
||||
}
|
||||
},
|
||||
openSender(m) {
|
||||
if (m.fromId) uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(m.fromId) })
|
||||
},
|
||||
goMore() {
|
||||
if (this.isGroup) uni.navigateTo({ url: '/pages/chat/group?sid=' + encodeURIComponent(this.sid) })
|
||||
else uni.navigateTo({ url: '/pages/chat/setting?sid=' + encodeURIComponent(this.sid) })
|
||||
},
|
||||
mention() {
|
||||
const c = store.findConv(this.sid)
|
||||
const ids = ((c && c.memberIds) || []).filter((id) => !store.me || id !== store.me.id)
|
||||
const names = ids.map((id) => {
|
||||
const p = store.person(id)
|
||||
return (p && p.name) || id
|
||||
}).filter(Boolean).slice(0, 6)
|
||||
if (!names.length) {
|
||||
this.draft = (this.draft || '') + '@'
|
||||
return
|
||||
}
|
||||
uni.showActionSheet({
|
||||
itemList: names,
|
||||
success: (res) => {
|
||||
const name = names[res.tapIndex]
|
||||
if (name) this.draft = (this.draft || '') + '@' + name + ' '
|
||||
}
|
||||
})
|
||||
},
|
||||
async startCall(video) {
|
||||
this.more = false
|
||||
if (!this.peerId) return
|
||||
try {
|
||||
await store.startCall(this.peerId, { video: !!video })
|
||||
} catch (e) {
|
||||
toast((e && e.message) || '无法发起通话')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat { display: flex; flex-direction: column; height: 100vh; overflow: hidden; background: #ededed; }
|
||||
.msgs { flex: 1; height: 0; padding: 12rpx 20rpx 8rpx; box-sizing: border-box; }
|
||||
.chip-time { text-align: center; margin: 16rpx auto; background: rgba(0,0,0,.08); color: #fff; font-size: 20rpx; padding: 4rpx 16rpx; border-radius: 8rpx; width: fit-content; }
|
||||
.sys { text-align: center; color: #888; font-size: 22rpx; padding: 12rpx 0 20rpx; }
|
||||
.empty { text-align: center; color: #888; padding: 80rpx 0; font-size: 26rpx; }
|
||||
.row { margin-bottom: 22rpx; display: flex; align-items: flex-start; gap: 12rpx; }
|
||||
.row.mine { justify-content: flex-end; }
|
||||
.row.pick { padding-left: 0; }
|
||||
.ck { width: 36rpx; height: 36rpx; border: 2rpx solid #bbb; border-radius: 50%; margin-top: 20rpx; flex-shrink: 0; }
|
||||
.ck.on { background: #07c160; border-color: #07c160; }
|
||||
.col { max-width: 70%; display: flex; flex-direction: column; }
|
||||
.col.mine { align-items: flex-end; }
|
||||
.av { width: 80rpx; height: 80rpx; border-radius: 10rpx; background: #dae2fd; color: #004ac6; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 700; }
|
||||
.av.self { background: #2563eb; color: #fff; }
|
||||
.av image { width: 80rpx; height: 80rpx; }
|
||||
.who { font-size: 20rpx; color: #888; margin-bottom: 4rpx; }
|
||||
.ball { background: #fff; padding: 16rpx 20rpx; border-radius: 8rpx; font-size: 30rpx; word-break: break-all; line-height: 1.45; }
|
||||
.ball.self { background: #95ec69; color: #111; }
|
||||
.ball.work { background: #fff; }
|
||||
.ball.voice { min-width: 140rpx; }
|
||||
.quote { background: rgba(0,0,0,.06); color: #666; font-size: 22rpx; padding: 8rpx 12rpx; border-radius: 6rpx; margin-bottom: 10rpx; }
|
||||
.pic { max-width: 420rpx; border-radius: 8rpx; display: block; }
|
||||
.file, .cardtxt { display: flex; flex-direction: column; }
|
||||
.fn, .ct { font-weight: 600; }
|
||||
.fs, .cs { font-size: 22rpx; margin-top: 6rpx; color: #2563eb; }
|
||||
.tm { font-size: 20rpx; color: #b2b2b2; margin-top: 6rpx; }
|
||||
.tm.read { color: #07c160; }
|
||||
.recmask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.01);
|
||||
touch-action: none;
|
||||
}
|
||||
.recbox { width: 360rpx; height: 280rpx; background: rgba(0,0,0,.72); color: #fff; border-radius: 20rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 26rpx; }
|
||||
.recbox.cancel { background: rgba(186,26,26,.85); }
|
||||
.recsec { font-size: 48rpx; font-weight: 700; margin-bottom: 12rpx; }
|
||||
.composer { background: #f7f7f7; border-top: 1rpx solid #e5e5e5; }
|
||||
.qbar { display: flex; align-items: center; padding: 10rpx 20rpx 0; }
|
||||
.qt { flex: 1; font-size: 22rpx; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qx { padding: 0 12rpx; color: #888; }
|
||||
.bar { display: flex; align-items: flex-end; padding: 12rpx 12rpx 0; gap: 10rpx; }
|
||||
.tool { width: 64rpx; height: 64rpx; text-align: center; line-height: 64rpx; font-size: 36rpx; color: #111; }
|
||||
.plus { font-size: 44rpx; font-weight: 300; }
|
||||
.box { flex: 1; min-height: 72rpx; background: #fff; border-radius: 8rpx; display: flex; align-items: center; padding: 0 16rpx; }
|
||||
.box input { flex: 1; height: 72rpx; }
|
||||
.hold {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
background: #fff;
|
||||
border-radius: 8rpx;
|
||||
text-align: center;
|
||||
line-height: 72rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.go { height: 64rpx; padding: 0 24rpx; background: #07c160; color: #fff; border-radius: 8rpx; font-size: 26rpx; font-weight: 600; display: flex; align-items: center; margin-bottom: 4rpx; }
|
||||
.panel { display: flex; flex-wrap: wrap; background: #f7f7f7; padding: 20rpx 24rpx 24rpx; gap: 24rpx; }
|
||||
.panel.emo { gap: 8rpx; }
|
||||
.tile { width: 140rpx; height: 140rpx; background: #fff; border-radius: 16rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 22rpx; color: #333; }
|
||||
.te { font-size: 48rpx; margin-bottom: 8rpx; }
|
||||
.em { width: 12.5%; text-align: center; font-size: 44rpx; line-height: 88rpx; }
|
||||
.hint { text-align: center; font-size: 22rpx; color: #888; padding: 20rpx; }
|
||||
.ico { padding: 8rpx 16rpx; font-size: 36rpx; }
|
||||
.mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,.45); z-index: 40; display: flex; align-items: flex-end; }
|
||||
.sheet { width: 100%; background: #fff; border-radius: 24rpx 24rpx 0 0; padding: 28rpx 28rpx 12rpx; }
|
||||
.sheet-t { display: block; text-align: center; font-size: 30rpx; font-weight: 700; }
|
||||
.sheet-s { display: block; text-align: center; font-size: 22rpx; color: #888; margin: 8rpx 0 24rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sheet-grid { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
||||
.cell { width: calc(25% - 12rpx); height: 140rpx; background: #f7f7f7; border-radius: 16rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 22rpx; color: #333; }
|
||||
.ce { font-size: 36rpx; margin-bottom: 8rpx; color: #111; font-weight: 700; }
|
||||
.sheet-cancel { margin: 20rpx 0 8rpx; height: 88rpx; border-radius: 16rpx; background: #f7f7f7; display: flex; align-items: center; justify-content: center; font-size: 28rpx; }
|
||||
.selbar { display: flex; background: #fff; border-top: 1rpx solid #eee; }
|
||||
.selbtn { flex: 1; height: 96rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; }
|
||||
.selbtn.danger { color: #fa5151; }
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<fy-header title="发起群聊" back />
|
||||
<view class="card field">
|
||||
<input v-model="name" placeholder="群名称(必填)" />
|
||||
</view>
|
||||
<view class="hint">已选 {{ picked.length }} 人</view>
|
||||
<view class="card">
|
||||
<view v-for="p in people" :key="p.id" class="item" @click="toggle(p.id)">
|
||||
<view class="box" :class="{ on: picked.includes(p.id) }" />
|
||||
<view class="av">{{ p.name.slice(0, 1) }}</view>
|
||||
<view class="meta">
|
||||
<text class="n">{{ p.name }}</text>
|
||||
<text class="s">{{ p.title }} · {{ p.department }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pad">
|
||||
<view class="btn-primary" :class="{ off: !can }" @click="ok">创建并进入</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FyHeader from '../../components/fy-header.vue'
|
||||
import { store } from '../../utils/store.js'
|
||||
import { toast } from '../../utils/format.js'
|
||||
|
||||
export default {
|
||||
components: { FyHeader },
|
||||
data() {
|
||||
return { name: '', picked: [] }
|
||||
},
|
||||
computed: {
|
||||
people() {
|
||||
return Object.values(store.people).filter((p) => !store.me || p.id !== store.me.id)
|
||||
},
|
||||
can() {
|
||||
return String(this.name || '').trim() && this.picked.length >= 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggle(id) {
|
||||
const i = this.picked.indexOf(id)
|
||||
if (i >= 0) this.picked.splice(i, 1)
|
||||
else this.picked.push(id)
|
||||
},
|
||||
async ok() {
|
||||
if (!this.can) {
|
||||
toast('请填写群名并至少选一位同事')
|
||||
return
|
||||
}
|
||||
try {
|
||||
uni.showLoading({ title: '创建中…', mask: true })
|
||||
const conv = await store.createGroup(this.name.trim(), this.picked)
|
||||
uni.hideLoading()
|
||||
uni.redirectTo({ url: '/pages/chat/chat?sid=' + encodeURIComponent(conv.id) })
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
toast((e && e.message) || '创建失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field { margin: 24rpx; padding: 8rpx 24rpx; }
|
||||
.field input { height: 80rpx; font-size: 30rpx; }
|
||||
.hint { margin: 0 32rpx 12rpx; font-size: 22rpx; color: #737686; }
|
||||
.card { margin: 0 24rpx 24rpx; overflow: hidden; }
|
||||
.item { display: flex; align-items: center; padding: 20rpx 24rpx; }
|
||||
.box { width: 36rpx; height: 36rpx; border: 2rpx solid #c3c6d7; border-radius: 8rpx; margin-right: 16rpx; }
|
||||
.box.on { background: #2563eb; border-color: #2563eb; }
|
||||
.av { width: 72rpx; height: 72rpx; border-radius: 50%; background: #e2e7ff; color: #004ac6; display: flex; align-items: center; justify-content: center; font-weight: 600; }
|
||||
.meta { margin-left: 16rpx; }
|
||||
.n { display: block; font-size: 28rpx; font-weight: 600; }
|
||||
.s { font-size: 22rpx; color: #737686; }
|
||||
.pad { padding: 0 24rpx 40rpx; }
|
||||
.off { opacity: 0.45; }
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<fy-header title="选择聊天" back />
|
||||
<view class="search">
|
||||
<input v-model="q" placeholder="搜索" />
|
||||
</view>
|
||||
<view class="snip">
|
||||
<text>{{ hint }}</text>
|
||||
</view>
|
||||
<view v-if="convs.length" class="sec">
|
||||
<text>最近聊天</text>
|
||||
</view>
|
||||
<view class="list">
|
||||
<view v-if="!convs.length && !people.length" class="empty">暂无可转发对象</view>
|
||||
<view v-for="c in convs" :key="'c' + c.id" class="item" @click="pickConv(c)">
|
||||
<image v-if="c.avatar" class="av img" :src="c.avatar" mode="aspectFill" />
|
||||
<view v-else class="av">{{ (c.name || '?').slice(0, 1) }}</view>
|
||||
<text class="n">{{ c.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="people.length" class="sec">通讯录</view>
|
||||
<view class="list">
|
||||
<view v-for="p in people" :key="'p' + p.id" class="item" @click="pickPerson(p)">
|
||||
<image v-if="p.avatar" class="av img" :src="p.avatar" mode="aspectFill" />
|
||||
<view v-else class="av">{{ (p.name || '?').slice(0, 1) }}</view>
|
||||
<text class="n">{{ p.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FyHeader from '../../components/fy-header.vue'
|
||||
import { store, previewOf } from '../../utils/store.js'
|
||||
import { toast } from '../../utils/format.js'
|
||||
|
||||
export default {
|
||||
components: { FyHeader },
|
||||
data() {
|
||||
return { fromSid: '', msgs: [], merge: false, q: '' }
|
||||
},
|
||||
computed: {
|
||||
hint() {
|
||||
if (!this.msgs.length) return '没有可转发的消息'
|
||||
if (this.msgs.length === 1) return '将转发:' + previewOf(this.msgs[0])
|
||||
return this.merge ? ('合并转发 ' + this.msgs.length + ' 条消息') : ('逐条转发 ' + this.msgs.length + ' 条消息')
|
||||
},
|
||||
convs() {
|
||||
const q = this.q.trim()
|
||||
return store.conversations.filter((c) => {
|
||||
if (!c || c.id === this.fromSid) return false
|
||||
if (c.type === 'work' || c.type === 'files') return false
|
||||
if (q && String(c.name || '').indexOf(q) < 0) return false
|
||||
return true
|
||||
})
|
||||
},
|
||||
people() {
|
||||
const seen = new Set(
|
||||
this.convs.filter((c) => c.type === 'direct' && c.peerId).map((c) => String(c.peerId))
|
||||
)
|
||||
const me = store.me && store.me.id
|
||||
const q = this.q.trim()
|
||||
return Object.values(store.people).filter((p) => {
|
||||
if (!p || !p.id || p.id === me || seen.has(String(p.id))) return false
|
||||
if (q && (p.name + p.department + (p.phone || '')).indexOf(q) < 0) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
const pack = store.pendingForward || {}
|
||||
this.fromSid = pack.fromSid || ''
|
||||
this.merge = !!pack.merge
|
||||
this.msgs = pack.msgs || (pack.msg ? [pack.msg] : [])
|
||||
if (!this.msgs.length) {
|
||||
toast('没有可转发的消息')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
store.pendingForward = null
|
||||
},
|
||||
methods: {
|
||||
done() {
|
||||
toast('已转发')
|
||||
store.pendingForward = null
|
||||
uni.navigateBack()
|
||||
},
|
||||
sendTo(c) {
|
||||
store.forwardMany(c, this.msgs, this.merge)
|
||||
this.done()
|
||||
},
|
||||
pickConv(c) { this.sendTo(c) },
|
||||
pickPerson(p) { this.sendTo(store.openDirect(p)) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search { padding: 12rpx 24rpx; }
|
||||
.search input { height: 72rpx; background: #ededed; border-radius: 8rpx; padding: 0 24rpx; font-size: 26rpx; }
|
||||
.snip { padding: 8rpx 32rpx 16rpx; font-size: 22rpx; color: #888; }
|
||||
.sec { padding: 16rpx 32rpx 8rpx; font-size: 22rpx; color: #888; background: #f7f7f7; }
|
||||
.item { display: flex; align-items: center; padding: 20rpx 24rpx; background: #fff; }
|
||||
.av { width: 80rpx; height: 80rpx; border-radius: 10rpx; background: #e2e7ff; color: #004ac6; display: flex; align-items: center; justify-content: center; font-weight: 700; overflow: hidden; flex-shrink: 0; }
|
||||
.av.img { width: 80rpx; height: 80rpx; }
|
||||
.n { margin-left: 20rpx; font-size: 30rpx; }
|
||||
.empty { padding: 80rpx; text-align: center; color: #888; }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<fy-header title="群聊设置" back />
|
||||
<view class="card head">
|
||||
<view class="grid">
|
||||
<view v-for="p in members.slice(0, 4)" :key="p.id" class="cell">{{ (p.name || '?').slice(0, 1) }}</view>
|
||||
</view>
|
||||
<view class="meta">
|
||||
<text class="gn">{{ title }}</text>
|
||||
<text class="gc">{{ members.length }} 人 · 协同进行中</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="cap">
|
||||
<text>群成员 ({{ members.length }})</text>
|
||||
</view>
|
||||
<view class="mems">
|
||||
<view v-for="p in members" :key="p.id" class="mem" @click="open(p)">
|
||||
<view class="mav">{{ (p.name || '?').slice(0, 1) }}</view>
|
||||
<text class="mn">{{ p.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row" @click="goHistory">
|
||||
<text>查找聊天记录</text>
|
||||
<text class="val">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row">
|
||||
<text>群名称</text>
|
||||
<text class="val">{{ title }}</text>
|
||||
</view>
|
||||
<view class="row" @click="toggle('pinned')">
|
||||
<text>置顶聊天</text>
|
||||
<text class="sw" :class="{ on: pinned }">{{ pinned ? '开' : '关' }}</text>
|
||||
</view>
|
||||
<view class="row" @click="toggle('muted')">
|
||||
<text>消息免打扰</text>
|
||||
<text class="sw" :class="{ on: muted }">{{ muted ? '开' : '关' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row danger" @click="clear">清空聊天记录</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FyHeader from '../../components/fy-header.vue'
|
||||
import { store } from '../../utils/store.js'
|
||||
|
||||
export default {
|
||||
components: { FyHeader },
|
||||
data() {
|
||||
return { sid: '', title: '群聊', members: [], pinned: false, muted: false }
|
||||
},
|
||||
onLoad(q) {
|
||||
this.sid = decodeURIComponent(q.sid || '')
|
||||
},
|
||||
onShow() {
|
||||
this.pull()
|
||||
},
|
||||
methods: {
|
||||
pull() {
|
||||
const c = store.findConv(this.sid)
|
||||
this.title = (c && c.name) || '群聊'
|
||||
this.pinned = !!(c && c.pinned)
|
||||
this.muted = !!(c && c.muted)
|
||||
const ids = (c && c.memberIds) || []
|
||||
this.members = ids.map((id) => store.person(id) || { id, name: id, title: '', department: '' })
|
||||
},
|
||||
open(p) {
|
||||
uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(p.id) })
|
||||
},
|
||||
toggle(field) {
|
||||
const c = store.findConv(this.sid)
|
||||
if (!c) return
|
||||
store.setSession(c, { [field]: !c[field] })
|
||||
this.pull()
|
||||
},
|
||||
goHistory() {
|
||||
uni.navigateTo({ url: '/pages/chat/history?sid=' + encodeURIComponent(this.sid) })
|
||||
},
|
||||
clear() {
|
||||
const c = store.findConv(this.sid)
|
||||
if (!c) return
|
||||
uni.showModal({
|
||||
title: '清空聊天记录',
|
||||
content: '将清空本机该群的聊天记录。',
|
||||
success: (r) => {
|
||||
if (!r.confirm) return
|
||||
store.clearHistory(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { margin: 24rpx; padding: 28rpx; display: flex; align-items: center; }
|
||||
.grid { width: 96rpx; height: 96rpx; background: #eaedff; border-radius: 16rpx; display: flex; flex-wrap: wrap; padding: 8rpx; }
|
||||
.cell { width: 36rpx; height: 36rpx; margin: 2rpx; background: #dbe1ff; color: #004ac6; font-size: 18rpx; font-weight: 700; display: flex; align-items: center; justify-content: center; border-radius: 6rpx; }
|
||||
.meta { margin-left: 20rpx; }
|
||||
.gn { font-size: 32rpx; font-weight: 700; display: block; }
|
||||
.gc { font-size: 22rpx; color: #737686; }
|
||||
.card { margin: 0 24rpx 24rpx; overflow: hidden; }
|
||||
.cap { padding: 20rpx 24rpx 8rpx; font-size: 26rpx; font-weight: 700; }
|
||||
.mems { display: flex; flex-wrap: wrap; padding: 8rpx 16rpx 24rpx; }
|
||||
.mem { width: 20%; display: flex; flex-direction: column; align-items: center; margin-bottom: 16rpx; }
|
||||
.mav { width: 80rpx; height: 80rpx; border-radius: 16rpx; background: #dbe1ff; color: #00174b; display: flex; align-items: center; justify-content: center; font-weight: 700; }
|
||||
.mn { font-size: 20rpx; margin-top: 8rpx; }
|
||||
.row { display: flex; justify-content: space-between; padding: 28rpx 24rpx; font-size: 28rpx; border-bottom: 1rpx solid #eaedff; }
|
||||
.val { color: #737686; }
|
||||
.sw { color: #737686; }
|
||||
.sw.on { color: #07c160; font-weight: 600; }
|
||||
.danger { color: #fa5151; justify-content: center; border-bottom: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<fy-header title="查找聊天记录" back />
|
||||
<view class="search">
|
||||
<input v-model="q" placeholder="搜索文字 / 文件名" confirm-type="search" focus />
|
||||
</view>
|
||||
<view v-if="!q.trim()" class="empty">输入关键字查找本会话记录</view>
|
||||
<view v-else-if="!hits.length" class="empty">没有找到相关记录</view>
|
||||
<view v-for="m in hits" :key="m.id" class="item" @click="open(m)">
|
||||
<text class="who">{{ who(m) }} · {{ timeOf(m) }}</text>
|
||||
<text class="txt">{{ preview(m) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FyHeader from '../../components/fy-header.vue'
|
||||
import { store, previewOf } from '../../utils/store.js'
|
||||
import { fmtTime } from '../../utils/format.js'
|
||||
|
||||
export default {
|
||||
components: { FyHeader },
|
||||
data() {
|
||||
return { sid: '', q: '' }
|
||||
},
|
||||
computed: {
|
||||
hits() {
|
||||
const q = this.q.trim()
|
||||
const c = store.findConv(this.sid)
|
||||
if (!q || !c) return []
|
||||
return (c.messages || []).filter((m) => {
|
||||
if (m.recalled) return false
|
||||
return String(m.text || '') .indexOf(q) >= 0 || String(m.fileName || '').indexOf(q) >= 0
|
||||
}).slice(-80).reverse()
|
||||
}
|
||||
},
|
||||
onLoad(q) {
|
||||
this.sid = decodeURIComponent(q.sid || '')
|
||||
},
|
||||
methods: {
|
||||
preview(m) { return previewOf(m) },
|
||||
timeOf(m) { return fmtTime(m.time) },
|
||||
who(m) {
|
||||
if (m.mine) return (store.me && store.me.name) || '我'
|
||||
const p = store.person(m.fromId)
|
||||
const c = store.findConv(this.sid)
|
||||
return (p && p.name) || (c && c.name) || '同事'
|
||||
},
|
||||
open(m) {
|
||||
if (m.kind === 'image' && m.fileUrl) {
|
||||
uni.previewImage({ urls: [m.fileUrl], current: m.fileUrl })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search { padding: 16rpx 24rpx; }
|
||||
.search input { height: 72rpx; background: #ededed; border-radius: 8rpx; padding: 0 24rpx; }
|
||||
.empty { text-align: center; color: #888; padding: 80rpx 24rpx; font-size: 26rpx; }
|
||||
.item { margin: 0 24rpx; padding: 20rpx 0; border-bottom: 1rpx solid #f0f0f0; }
|
||||
.who { display: block; font-size: 22rpx; color: #888; }
|
||||
.txt { display: block; font-size: 28rpx; margin-top: 6rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<fy-header title="聊天信息" back />
|
||||
<view v-if="peer" class="card head" @click="goPeer">
|
||||
<image v-if="peer.avatar" class="av img" :src="peer.avatar" mode="aspectFill" />
|
||||
<view v-else class="av">{{ (peer.name || '?').slice(0, 1) }}</view>
|
||||
<view class="meta">
|
||||
<text class="n">{{ peer.name }}</text>
|
||||
<text class="s">{{ peer.department }}{{ peer.title ? ' · ' + peer.title : '' }}</text>
|
||||
</view>
|
||||
<text class="arr">›</text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row" @click="goHistory">
|
||||
<text>查找聊天记录</text>
|
||||
<text class="arr">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row" @click="toggle('pinned')">
|
||||
<text>置顶聊天</text>
|
||||
<text class="sw" :class="{ on: pinned }">{{ pinned ? '开' : '关' }}</text>
|
||||
</view>
|
||||
<view class="row" @click="toggle('muted')">
|
||||
<text>消息免打扰</text>
|
||||
<text class="sw" :class="{ on: muted }">{{ muted ? '开' : '关' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row" @click="call(false)">
|
||||
<text>语音通话</text>
|
||||
<text class="arr">›</text>
|
||||
</view>
|
||||
<view class="row" @click="call(true)">
|
||||
<text>视频通话</text>
|
||||
<text class="arr">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row danger" @click="clear">清空聊天记录</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FyHeader from '../../components/fy-header.vue'
|
||||
import { store } from '../../utils/store.js'
|
||||
import { toast } from '../../utils/format.js'
|
||||
|
||||
export default {
|
||||
components: { FyHeader },
|
||||
data() {
|
||||
return { sid: '', pinned: false, muted: false, peer: null }
|
||||
},
|
||||
onLoad(q) {
|
||||
this.sid = decodeURIComponent(q.sid || '')
|
||||
},
|
||||
onShow() { this.pull() },
|
||||
methods: {
|
||||
pull() {
|
||||
const c = store.findConv(this.sid)
|
||||
this.pinned = !!(c && c.pinned)
|
||||
this.muted = !!(c && c.muted)
|
||||
this.peer = c && c.peerId ? store.person(c.peerId) : null
|
||||
if (!this.peer && c) this.peer = { name: c.name, avatar: c.avatar, department: '', title: '' }
|
||||
},
|
||||
toggle(field) {
|
||||
const c = store.findConv(this.sid)
|
||||
if (!c) return
|
||||
store.setSession(c, { [field]: !c[field] })
|
||||
this.pull()
|
||||
},
|
||||
goPeer() {
|
||||
const c = store.findConv(this.sid)
|
||||
if (c && c.peerId) uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(c.peerId) })
|
||||
},
|
||||
goHistory() {
|
||||
uni.navigateTo({ url: '/pages/chat/history?sid=' + encodeURIComponent(this.sid) })
|
||||
},
|
||||
call(video) {
|
||||
const c = store.findConv(this.sid)
|
||||
if (!c || !c.peerId) return
|
||||
store.startCall(c.peerId, { video }).catch((e) => toast((e && e.message) || '无法发起通话'))
|
||||
},
|
||||
clear() {
|
||||
const c = store.findConv(this.sid)
|
||||
if (!c) return
|
||||
uni.showModal({
|
||||
title: '清空聊天记录',
|
||||
content: '将清空本机该会话的聊天记录。',
|
||||
success: (r) => {
|
||||
if (!r.confirm) return
|
||||
store.clearHistory(c)
|
||||
toast('已清空')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { margin: 24rpx; padding: 28rpx; display: flex; align-items: center; }
|
||||
.av { width: 96rpx; height: 96rpx; border-radius: 12rpx; background: #004ac6; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 700; overflow: hidden; }
|
||||
.av.img { width: 96rpx; height: 96rpx; }
|
||||
.meta { flex: 1; margin-left: 20rpx; }
|
||||
.n { display: block; font-size: 32rpx; font-weight: 700; }
|
||||
.s { font-size: 22rpx; color: #737686; }
|
||||
.arr { color: #c3c6d7; }
|
||||
.card { margin: 0 24rpx 24rpx; overflow: hidden; }
|
||||
.row { display: flex; justify-content: space-between; padding: 28rpx 24rpx; font-size: 28rpx; border-bottom: 1rpx solid #f0f0f0; }
|
||||
.sw { color: #737686; }
|
||||
.sw.on { color: #07c160; font-weight: 600; }
|
||||
.danger { color: #fa5151; justify-content: center; border-bottom: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<fy-header title="工作通知" back />
|
||||
<view class="bar">
|
||||
<text>审批 · 协同 · 投标 · 用章 · 公告</text>
|
||||
<text class="badge">{{ items.length }} 项待办</text>
|
||||
</view>
|
||||
<view v-if="!items.length" class="empty">暂无待办通知</view>
|
||||
<view v-for="t in items" :key="t.no || t.id" class="card item" @click="open(t)">
|
||||
<view class="stripe" />
|
||||
<view class="body">
|
||||
<view class="top">
|
||||
<text class="kind">{{ t.typeLabel || t.tag || t.type || '待办' }}</text>
|
||||
<text class="time">{{ t.createdAt || t.time || '' }}</text>
|
||||
</view>
|
||||
<text class="title">{{ t.title || t.summary || t.no }}</text>
|
||||
<text class="sub">{{ t.submitter || t.applicant || '' }}{{ t.dept ? ' · ' + t.dept : '' }}</text>
|
||||
<view class="acts">
|
||||
<text class="go">去办理</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FyHeader from '../../components/fy-header.vue'
|
||||
import { store } from '../../utils/store.js'
|
||||
import { asList, fmtTime } from '../../utils/format.js'
|
||||
import { oaGet } from '../../utils/api.js'
|
||||
|
||||
export default {
|
||||
components: { FyHeader },
|
||||
data() {
|
||||
return { items: [] }
|
||||
},
|
||||
onShow() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
try {
|
||||
const page = await oaGet('/workflow/requests?tab=pending&page=1&size=50', store.token)
|
||||
this.items = asList(page && page.records ? page.records : page).map((t) => ({
|
||||
...t,
|
||||
createdAt: fmtTime(t.createdAt || t.time)
|
||||
}))
|
||||
} catch (_) {
|
||||
this.items = store.todos || []
|
||||
}
|
||||
const conv = store.conversations.find((c) => c.type === 'work')
|
||||
if (conv) conv.unread = 0
|
||||
},
|
||||
open(t) {
|
||||
const no = t.no || t.id || ''
|
||||
if (String(t.source || t.type || '') === 'work' || t.taskId) {
|
||||
uni.navigateTo({ url: '/pages/coop/detail?id=' + encodeURIComponent(t.taskId || t.id || no) })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(no) })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bar { margin: 16rpx 24rpx; background: #f2f3ff; border-radius: 16rpx; padding: 16rpx 20rpx; display: flex; justify-content: space-between; font-size: 22rpx; color: #434655; }
|
||||
.item { margin: 0 24rpx 20rpx; display: flex; overflow: hidden; }
|
||||
.stripe { width: 8rpx; background: #2563eb; }
|
||||
.body { flex: 1; padding: 24rpx; }
|
||||
.top { display: flex; justify-content: space-between; }
|
||||
.kind { font-size: 28rpx; font-weight: 700; }
|
||||
.time { font-size: 22rpx; color: #737686; }
|
||||
.title { display: block; font-size: 28rpx; font-weight: 600; margin: 12rpx 0 6rpx; }
|
||||
.sub { font-size: 22rpx; color: #737686; }
|
||||
.acts { margin-top: 16rpx; }
|
||||
.go { color: #2563eb; font-size: 24rpx; font-weight: 600; }
|
||||
.empty { text-align: center; color: #737686; padding: 80rpx; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user