oa_app仓库初始化

This commit is contained in:
2026-09-23 16:52:48 +08:00
commit 65c5e9f66f
82 changed files with 6490 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
<template>
<view class="page">
<fy-header :title="spec ? spec.title : '申请'" back />
<view v-if="spec" class="body">
<view class="field">
<text class="lab">标题</text>
<view class="box"><input v-model="title" /></view>
</view>
<view v-if="spec.multiInvoice">
<view v-for="(inv, i) in invoices" :key="i" class="card inv">
<text class="lab">发票 {{ i + 1 }}</text>
<picker :range="ticketTypes" @change="(e) => inv.ticketType = ticketTypes[e.detail.value]">
<view class="box">{{ inv.ticketType || '选择票种' }}</view>
</picker>
<view class="box"><input v-model="inv.amount" type="digit" placeholder="金额" /></view>
<view class="box"><input v-model="inv.invoiceNo" placeholder="发票号(选填)" /></view>
<picker mode="date" @change="(e) => inv.date = e.detail.value">
<view class="box">{{ inv.date || '发票日期(选填)' }}</view>
</picker>
<view class="box"><input v-model="inv.note" placeholder="备注" /></view>
<fy-files :files="inv.files" label="发票附件" editable kind="expense" @update:files="(rows) => inv.files = rows" @remove="(idx) => inv.files.splice(idx, 1)" />
</view>
<text class="link" @click="invoices.push({ ticketType: '', amount: '', invoiceNo: '', date: '', note: '', files: [], proxyPay: false })"> 添加发票</text>
<view class="field">
<text class="lab">报销说明</text>
<view class="box tall"><textarea v-model="remark" /></view>
</view>
</view>
<view v-for="f in spec.fields || []" :key="f.key" class="field">
<text class="lab">{{ f.label }}</text>
<picker v-if="f.type === 'select'" :range="optionsOf(f)" @change="(e) => values[f.key] = optionsOf(f)[e.detail.value]">
<view class="box">{{ values[f.key] || '请选择' }}</view>
</picker>
<picker v-else-if="f.type === 'date'" mode="date" @change="(e) => values[f.key] = e.detail.value">
<view class="box">{{ values[f.key] || '请选择日期' }}</view>
</picker>
<picker v-else-if="f.type === 'datetime'" mode="date" @change="(e) => values[f.key] = e.detail.value">
<view class="box">{{ values[f.key] || '请选择日期' }}</view>
</picker>
<view v-else-if="f.type === 'textarea'" class="box tall"><textarea v-model="values[f.key]" :placeholder="f.placeholder" /></view>
<view v-else class="box"><input v-model="values[f.key]" :placeholder="f.placeholder" :type="f.type === 'money' || f.type === 'number' ? 'digit' : 'text'" /></view>
</view>
<fy-files v-if="!spec.multiInvoice" :files="attachments" label="附件" editable :kind="kind" @update:files="(rows) => attachments = rows" @remove="(idx) => attachments.splice(idx, 1)" />
<view v-if="!fixed" class="field">
<text class="lab">审批人</text>
<picker :range="approverNames" @change="pickApprover">
<view class="box">{{ approverLabel || '请选择审批人' }}</view>
</picker>
</view>
<view v-else class="field">
<text class="lab">审批链</text>
<text class="hint">{{ suggestedLabel || '将按固定审批链流转' }}</text>
</view>
<view class="btn-primary" @click="submit">提交申请</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import FyFiles from '../../components/fy-files.vue'
import { store } from '../../utils/store.js'
import { applyKindById, buildApplyPayload, isFixedChain } from '../../utils/apply.js'
import { asList, toast } from '../../utils/format.js'
import { oaGet } from '../../utils/api.js'
export default {
components: { FyHeader, FyFiles },
data() {
return {
kind: 'leave',
title: '',
values: {},
invoices: [{ ticketType: '', amount: '', invoiceNo: '', date: '', note: '', files: [], proxyPay: false }],
attachments: [],
remark: '',
approvers: [],
suggested: '',
suggestedName: '',
approver: '',
ticketTypes: ['机票', '高铁/火车', '出租车', '住宿', '餐饮招待', '办公用品', '业务招待', '过路费/油费', '其他'],
peopleOpts: []
}
},
computed: {
spec() { return applyKindById(this.kind) },
fixed() { return isFixedChain(this.kind) },
approverNames() { return this.approvers.map((a) => a.realName || a.name || a.username) },
approverLabel() {
const a = this.approvers.find((x) => x.username === this.approver)
return a ? (a.realName || a.name || a.username) : ''
},
suggestedLabel() { return this.suggestedName }
},
onLoad(q) {
this.kind = q.kind || 'leave'
const spec = this.spec
const me = store.me
if (me && spec) this.title = me.name + ' ' + spec.title
this.boot()
},
methods: {
optionsOf(f) {
if (f.key === 'targetUsername') return this.peopleOpts.map((p) => p.name)
return f.options || []
},
async boot() {
try {
const preview = await oaGet('/workflow/chain-preview?type=' + encodeURIComponent(this.kind), store.token)
this.approvers = asList(preview && preview.approvers)
const chain = asList(preview && preview.suggested)
if (chain[0]) {
this.suggested = chain[0].username || ''
this.suggestedName = chain[0].realName || chain[0].name || this.suggested
}
if (!this.fixed && !this.approver) this.approver = this.suggested || (this.approvers[0] && this.approvers[0].username) || ''
} catch (_) {}
if (this.kind === 'layoff') {
this.peopleOpts = Object.values(store.people)
}
},
pickApprover(e) {
const a = this.approvers[e.detail.value]
this.approver = a ? a.username : ''
},
async submit() {
if (!this.spec) return
if (!store.can('personal', 'apply')) return toast('没有发起申请权限')
if (!this.title.trim()) return toast('请填写标题')
if (this.spec.multiInvoice) {
const missing = this.invoices.some((r) => Number(r.amount) > 0 && !(r.files && r.files.length))
if (missing) return toast('请为有金额的发票上传附件')
}
let layoffName = ''
if (this.kind === 'layoff' && this.values.targetUsername) {
const p = this.peopleOpts.find((x) => x.name === this.values.targetUsername || x.username === this.values.targetUsername)
if (p) {
this.values.targetUsername = p.username
layoffName = p.name
}
}
const body = buildApplyPayload({
spec: this.spec,
title: this.title,
values: this.values,
invoices: this.invoices,
expenseRemark: this.remark,
approverUsername: this.approver,
suggestedUsername: this.suggested,
me: store.me,
layoffTargetName: layoffName,
attachments: this.attachments
})
try {
await store.submitApply(body)
toast('已提交', 'success')
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {
toast(e.message || '提交失败')
}
}
}
}
</script>
<style scoped>
.body { padding: 24rpx; padding-bottom: 80rpx; }
.field { margin-bottom: 20rpx; }
.lab { font-size: 24rpx; color: #434655; display: block; margin-bottom: 8rpx; }
.box { min-height: 88rpx; background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; font-size: 28rpx; }
.box.tall { min-height: 160rpx; }
.box textarea, .box input { width: 100%; }
.inv { padding: 20rpx; margin-bottom: 16rpx; }
.inv .box { margin-top: 12rpx; }
.link { color: #2563eb; font-size: 26rpx; display: block; margin-bottom: 20rpx; }
.hint { font-size: 24rpx; color: #737686; }
</style>
+67
View File
@@ -0,0 +1,67 @@
<template>
<view class="page">
<fy-header title="发起申请" back />
<view v-if="!canApply" class="deny">当前账号没有发起申请权限</view>
<view v-else>
<view class="search">
<input v-model="q" placeholder="搜索审批流程或申请事项..." />
</view>
<view v-for="g in groups" :key="g.category" class="block">
<text class="cat">{{ g.category }}</text>
<view class="card">
<view v-for="k in g.items" :key="k.kind" class="item" @click="open(k)">
<view>
<text class="t">{{ k.title }}</text>
<text class="d">{{ k.hint }}</text>
</view>
<text class="arr"></text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { groupedApplyKinds, visibleApplyKinds } from '../../utils/apply.js'
export default {
components: { FyHeader },
data() { return { q: '' } },
computed: {
canApply() { return store.can('personal', 'apply') },
groups() {
const me = store.me || {}
let kinds = visibleApplyKinds({
employeeStatus: me.employeeStatus,
isSuper: me.isSuper,
role: me.title
})
if (this.q.trim()) {
const q = this.q.trim()
kinds = kinds.filter((k) => (k.title + k.hint + k.group).includes(q))
}
return groupedApplyKinds(kinds)
}
},
methods: {
open(k) {
uni.navigateTo({ url: '/pages/apply/form?kind=' + k.kind })
}
}
}
</script>
<style scoped>
.search { padding: 16rpx 24rpx; }
.search input { height: 80rpx; background: #f2f3ff; border-radius: 16rpx; padding: 0 24rpx; }
.block { margin: 8rpx 24rpx 24rpx; }
.cat { font-size: 22rpx; color: #434655; font-weight: 600; }
.item { display: flex; justify-content: space-between; align-items: center; padding: 24rpx; border-bottom: 1rpx solid #eaedff; }
.t { display: block; font-size: 28rpx; font-weight: 600; }
.d { font-size: 22rpx; color: #737686; }
.arr { color: #c3c6d7; }
.deny { padding: 80rpx; text-align: center; color: #737686; }
</style>
+59
View File
@@ -0,0 +1,59 @@
<template>
<view class="page">
<fy-header :title="title" back />
<view v-if="loading" class="empty">加载中</view>
<view v-else-if="!rows.length" class="empty">暂无记录</view>
<view v-for="r in rows" :key="r.id || r.no" class="card item" @click="open(r)">
<view>
<text class="t">{{ r.title || r.no || r.typeLabel }}</text>
<text class="s">{{ r.status || '' }} · {{ r.createdAt || r.date || '' }}</text>
</view>
<text class="amt">{{ r.amount != null ? '¥' + r.amount : '' }}</text>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { asList } from '../../utils/format.js'
import { oaGet } from '../../utils/api.js'
export default {
components: { FyHeader },
data() { return { title: '单据', type: 'expense', rows: [], loading: false } },
onLoad(q) {
this.type = q.type || 'expense'
this.title = decodeURIComponent(q.title || (this.type === 'loan' ? '我的借支' : this.type === 'seal' ? '我的用章' : '我的报销'))
this.load()
},
methods: {
async load() {
this.loading = true
try {
if (this.type === 'seal') {
this.rows = asList(await oaGet('/seals/logs?mine=1', store.token))
} else {
this.rows = asList(await oaGet(`/finance/bills?type=${this.type}&mine=1`, store.token))
}
} catch (_) {
this.rows = []
} finally {
this.loading = false
}
},
open(r) {
const no = r.no || r.requestNo
if (no) uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(no) })
}
}
}
</script>
<style scoped>
.item { margin: 16rpx 24rpx; padding: 24rpx; display: flex; justify-content: space-between; }
.t { display: block; font-size: 28rpx; font-weight: 600; }
.s { font-size: 22rpx; color: #737686; }
.amt { color: #004ac6; font-weight: 700; }
.empty { padding: 80rpx; text-align: center; color: #737686; }
</style>
+105
View File
@@ -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>
+799
View File
@@ -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>
+81
View File
@@ -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>
+109
View File
@@ -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>
+124
View File
@@ -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>
+65
View File
@@ -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>
+119
View File
@@ -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>
+79
View File
@@ -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>
+129
View File
@@ -0,0 +1,129 @@
<template>
<view class="page">
<fy-header title="同事资料" back />
<view v-if="p" class="body">
<view class="card profile">
<image v-if="p.avatar" class="av img" :src="p.avatar" mode="aspectFill" />
<view v-else class="av">{{ p.name.slice(0, 1) }}</view>
<view class="info">
<view class="nm">
<text class="n">{{ p.name }}</text>
<text class="tag">{{ p.employeeStatus || '在职' }}</text>
</view>
<text class="s">{{ p.title }} · {{ p.department }}</text>
<view class="ph" @click="copyPhone">
<text> {{ masked }}</text>
<text class="copy">复制</text>
</view>
</view>
</view>
<view class="acts">
<view class="act" @click="chat">
<text class="ai">💬</text>
<text>发消息</text>
</view>
<view class="act" @click="call">
<text class="ai"></text>
<text>语音通话</text>
</view>
<view class="act" @click="video">
<text class="ai"></text>
<text>视频通话</text>
</view>
</view>
<view class="card rows">
<view class="row">
<text class="k">所属部门</text>
<text class="v">{{ p.department || '—' }}</text>
</view>
<view class="row">
<text class="k">工号</text>
<text class="v">{{ p.jobId || '—' }}</text>
</view>
</view>
<view class="card rows">
<view class="row" @click="toggle('pinned')" v-if="conv">
<text class="k">置顶聊天</text>
<text class="v brand">{{ conv.pinned ? '已置顶' : '未置顶' }}</text>
</view>
<view class="row" @click="toggle('muted')" v-if="conv">
<text class="k">消息免打扰</text>
<text class="v brand">{{ conv.muted ? '已开启' : '未开启' }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { dmId, maskPhone, toast } from '../../utils/format.js'
export default {
components: { FyHeader },
data() { return { id: '', tick: 0 } },
computed: {
p() { return store.person(this.id) },
masked() { return maskPhone(this.p && this.p.phone) },
conv() {
this.tick
if (!this.p || !store.me) return null
return store.findConv(dmId(store.me.id, this.p.id))
}
},
onLoad(q) { this.id = decodeURIComponent(q.id || '') },
methods: {
chat() {
if (!this.p) return
const conv = store.openDirect(this.p)
uni.navigateTo({ url: '/pages/chat/chat?sid=' + encodeURIComponent(conv.id) })
},
call() {
if (!this.p) return
store.startCall(this.p.id, { video: false }).catch((e) => toast((e && e.message) || '无法发起通话'))
},
async video() {
if (!this.p) return
try {
await store.startCall(this.p.id, { video: true })
} catch (e) {
toast((e && e.message) || '暂无法发起视频通话')
}
},
copyPhone() {
if (!this.p || !this.p.phone) return
uni.setClipboardData({ data: String(this.p.phone) })
},
toggle(field) {
if (!this.conv) return
store.setSession(this.conv, { [field]: !this.conv[field] })
this.tick += 1
}
}
}
</script>
<style scoped>
.body { padding: 24rpx; }
.profile { padding: 28rpx; display: flex; align-items: center; }
.av { width: 128rpx; height: 128rpx; border-radius: 20rpx; background: #004ac6; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 48rpx; font-weight: 700; }
.av.img { border-radius: 20rpx; }
.info { margin-left: 24rpx; flex: 1; min-width: 0; }
.nm { display: flex; align-items: center; gap: 12rpx; }
.n { font-size: 36rpx; font-weight: 700; }
.tag { font-size: 20rpx; background: #e2e7ff; color: #434655; padding: 2rpx 10rpx; border-radius: 8rpx; }
.s, .ph { display: block; font-size: 24rpx; color: #737686; margin-top: 8rpx; }
.copy { color: #2563eb; margin-left: 12rpx; }
.acts { display: flex; gap: 16rpx; margin: 20rpx 0; }
.act { flex: 1; background: #fff; border-radius: 16rpx; padding: 20rpx 8rpx; display: flex; flex-direction: column; align-items: center; font-size: 22rpx; font-weight: 600; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.03); }
.ai { font-size: 36rpx; color: #004ac6; margin-bottom: 8rpx; }
.rows { overflow: hidden; margin-bottom: 20rpx; }
.row { display: flex; justify-content: space-between; padding: 28rpx 24rpx; font-size: 28rpx; border-bottom: 1rpx solid #eaedff; }
.k { color: #434655; }
.v { color: #131b2e; }
.brand { color: #2563eb; }
</style>
+74
View File
@@ -0,0 +1,74 @@
<template>
<view class="page">
<fy-header title="新建协同" back />
<view class="body">
<view class="field">
<text class="lab">标题</text>
<view class="box"><input v-model="title" placeholder="请输入任务标题" /></view>
</view>
<view class="field">
<text class="lab">办理人</text>
<picker :range="names" @change="pick">
<view class="box">{{ assigneeName || '请选择' }}</view>
</picker>
</view>
<view class="field">
<text class="lab">说明</text>
<view class="box tall"><textarea v-model="content" /></view>
</view>
<view class="btn-primary" @click="ok">创建</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { asList, toast } from '../../utils/format.js'
import { oaGet, oaPost } from '../../utils/api.js'
export default {
components: { FyHeader },
data() {
return { title: '', content: '', assignees: [], assignee: '', assigneeName: '' }
},
computed: {
names() { return this.assignees.map((a) => a.realName || a.name || a.username) }
},
onLoad() { this.boot() },
methods: {
async boot() {
try {
this.assignees = asList(await oaGet('/work-tasks/assignees', store.token))
} catch (_) {
this.assignees = Object.values(store.people)
}
},
pick(e) {
const a = this.assignees[e.detail.value]
this.assignee = a.username || a.id
this.assigneeName = a.realName || a.name || a.username
},
async ok() {
if (!this.title.trim()) return toast('请填写标题')
if (!this.assignee) return toast('请选择办理人')
try {
await oaPost('/work-tasks', { title: this.title, content: this.content, assignee: this.assignee }, store.token)
toast('已创建', 'success')
setTimeout(() => uni.navigateBack(), 400)
} catch (e) {
toast(e.message || '创建失败')
}
}
}
}
</script>
<style scoped>
.body { padding: 24rpx; }
.field { margin-bottom: 20rpx; }
.lab { font-size: 24rpx; color: #434655; display: block; margin-bottom: 8rpx; }
.box { min-height: 88rpx; background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; }
.box.tall { min-height: 160rpx; }
.box input, .box textarea { width: 100%; }
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<view class="page">
<fy-header title="协同详情" back />
<view v-if="row" class="body">
<view class="card">
<text class="t">{{ row.title }}</text>
<text class="s">发起 {{ row.assignerName || '' }} · 办理 {{ row.assigneeName || '' }}</text>
<text class="s">{{ row.content || row.remark || '' }}</text>
<text class="chip">{{ row.status === 'done' ? '已办结' : '待办理' }}</text>
</view>
<view v-if="canDone" class="btn-primary" @click="done">标记已办理</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { oaGet, oaPost } from '../../utils/api.js'
import { toast } from '../../utils/format.js'
export default {
components: { FyHeader },
data() { return { id: '', row: null } },
computed: {
canDone() {
if (!this.row || this.row.status === 'done') return false
const me = store.me
return me && (String(this.row.assigneeId) === me.id || String(this.row.assignee) === me.username)
}
},
onLoad(q) {
this.id = decodeURIComponent(q.id || '')
this.load()
},
methods: {
async load() {
try {
this.row = await oaGet('/work-tasks/' + encodeURIComponent(this.id), store.token)
} catch (_) {
this.row = { id: this.id, title: '协同任务' }
}
},
async done() {
try {
await oaPost('/work-tasks/' + encodeURIComponent(this.id) + '/done', {}, store.token)
toast('已办结', 'success')
this.load()
} catch (e) {
toast(e.message || '操作失败')
}
}
}
}
</script>
<style scoped>
.body { padding: 24rpx; }
.card { padding: 28rpx; margin-bottom: 24rpx; }
.t { display: block; font-size: 34rpx; font-weight: 700; }
.s { display: block; font-size: 24rpx; color: #737686; margin-top: 8rpx; }
.chip { display: inline-block; margin-top: 16rpx; }
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<view class="page">
<fy-header title="工作协同" back>
<template #right>
<text v-if="canCreate" class="add" @click="create"> 新建</text>
</template>
</fy-header>
<view class="sec">待我办理</view>
<view v-if="!pending.length" class="empty">暂无</view>
<view v-for="t in pending" :key="t.id" class="card item" @click="open(t)">
<view class="stripe" />
<view class="body">
<text class="t">{{ t.title }}</text>
<text class="s">{{ t.assignerName || '' }} · {{ t.dueAt || '' }}</text>
</view>
</view>
<view class="sec">我发起的</view>
<view v-for="t in mine" :key="'m' + t.id" class="card item" @click="open(t)">
<view class="body wide">
<text class="t">{{ t.title }}</text>
<text class="s">{{ t.assigneeName || '' }} · {{ t.status || '' }}</text>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { asList } from '../../utils/format.js'
import { oaGet } from '../../utils/api.js'
export default {
components: { FyHeader },
data() {
return { pending: [], mine: [] }
},
computed: {
canCreate() { return store.can('personal', 'view') }
},
onShow() { this.load() },
methods: {
async load() {
try {
this.pending = asList(await oaGet('/work-tasks?tab=pending', store.token))
this.mine = asList(await oaGet('/work-tasks?tab=review', store.token))
} catch (_) {}
},
open(t) {
uni.navigateTo({ url: '/pages/coop/detail?id=' + encodeURIComponent(t.id) })
},
create() {
uni.navigateTo({ url: '/pages/coop/create' })
}
}
}
</script>
<style scoped>
.sec { padding: 24rpx 24rpx 8rpx; font-size: 28rpx; font-weight: 700; }
.item { margin: 12rpx 24rpx; display: flex; overflow: hidden; }
.stripe { width: 8rpx; background: #2563eb; }
.body { flex: 1; padding: 24rpx; }
.body.wide { padding: 24rpx; }
.t { display: block; font-size: 28rpx; font-weight: 600; }
.s { font-size: 22rpx; color: #737686; }
.empty { padding: 24rpx; text-align: center; color: #737686; }
.add { color: #2563eb; font-size: 26rpx; font-weight: 700; padding: 8rpx; }
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<view class="page">
<fy-header :title="title" back />
<view v-if="!allowed" class="deny">当前账号没有{{ title }}查看权限</view>
<view v-else>
<scroll-view scroll-x class="tabs">
<text v-for="t in tabs" :key="t.id" class="tab" :class="{ on: tab === t.id }" @click="switchTab(t)">{{ t.title }}</text>
</scroll-view>
<view v-if="loading" class="empty">加载中</view>
<view v-else-if="!rows.length" class="empty">暂无数据</view>
<view v-for="r in rows" :key="r.id || r.no || r.code" class="card item">
<text class="t">{{ titleOf(r) }}</text>
<text class="s">{{ subOf(r) }}</text>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { ERP_MODULES, ERP_TABS } from '../../utils/catalog.js'
import { asList, firstText } from '../../utils/format.js'
import { oaGet } from '../../utils/api.js'
import { canMenu } from '../../utils/perms.js'
export default {
components: { FyHeader },
data() {
return { id: '', tab: '', rows: [], loading: false }
},
computed: {
meta() { return ERP_MODULES.find((m) => m.id === this.id) || { title: '业务', menu: this.id } },
title() { return this.meta.title },
allowed() { return store.can(this.meta.menu || this.id, 'view') },
tabs() {
const all = ERP_TABS[this.id] || [{ id: 'all', title: '全部' }]
return all.filter((t) => canMenu(store.menus, this.id, t.action || 'view', !!(store.me && store.me.isSuper)))
}
},
onLoad(q) {
this.id = q.id || 'bidding'
const first = this.tabs[0]
this.tab = first ? first.id : ''
if (this.allowed) this.load()
},
methods: {
switchTab(t) {
this.tab = t.id
this.load()
},
titleOf(r) {
return firstText(r, ['title', 'name', 'project', 'code', 'no', 'realName', 'customer'], '记录')
},
subOf(r) {
return firstText(r, ['status', 'stage', 'owner', 'dept', 'submitter', 'createdAt'], '')
},
async load() {
this.loading = true
try {
this.rows = asList(await oaGet(this.path(), store.token))
} catch (_) {
this.rows = []
} finally {
this.loading = false
}
},
path() {
const id = this.id
const tab = this.tab
if (id === 'bidding') {
if (tab === 'stats') return '/bid/registries'
return '/bid/cases'
}
if (id === 'project') {
if (tab === 'workhours') return '/project/hours'
if (tab === 'accept') return '/project/accepts'
return '/project/projects'
}
if (id === 'finance') return `/finance/bills?type=${tab === 'ledger' ? '' : tab}`
if (id === 'hr') {
if (tab === 'org') return '/org/depts'
if (tab === 'attendance') return '/hr/attendance'
if (tab === 'salary') return '/hr/payroll'
if (tab === 'notice') return '/announcements'
return '/hr/staff'
}
if (id === 'contracts') return `/contracts?kind=${tab}`
if (id === 'seals') {
if (tab === 'qualification') return '/seals/qualifications'
if (tab === 'borrow') return '/seals/borrows'
if (tab === 'manage') return '/seals'
return '/seals/logs'
}
if (id === 'crm') {
if (tab === 'opportunity') return '/crm/opportunities'
if (tab === 'pool') return '/crm/pool'
return '/crm/customers'
}
if (id === 'system') {
if (tab === 'attend') return '/system/attend-rules'
if (tab === 'logs') return '/system/logs'
if (tab === 'online') return '/system/online'
return '/system/perm-grants'
}
return '/dashboard/overview'
}
}
}
</script>
<style scoped>
.tabs { white-space: nowrap; padding: 16rpx 16rpx 0; }
.tab { display: inline-block; padding: 12rpx 20rpx; font-size: 24rpx; color: #737686; }
.tab.on { color: #2563eb; font-weight: 700; border-bottom: 4rpx solid #2563eb; }
.item { margin: 16rpx 24rpx; padding: 24rpx; }
.t { display: block; font-weight: 600; font-size: 28rpx; }
.s { font-size: 22rpx; color: #737686; }
.empty, .deny { padding: 80rpx 40rpx; text-align: center; color: #737686; }
</style>
+153
View File
@@ -0,0 +1,153 @@
<template>
<view class="page login">
<fy-header title="员工认证" />
<view class="body">
<view class="brand-box">
<view class="logo"></view>
<text class="name">风影随行通讯</text>
<text class="sub">员工凭据认证 · 企业内网安全通道</text>
</view>
<view class="field">
<text class="lab">手机号 / 企业工号</text>
<view class="box">
<text class="pre">+86</text>
<input v-model="phone" placeholder="请输入手机号或工号" />
</view>
</view>
<view class="field">
<text class="lab">账户密码</text>
<view class="box">
<input v-model="password" :password="!showPwd" placeholder="请输入密码" />
<text class="eye" @click="showPwd = !showPwd">{{ showPwd ? '' : '' }}</text>
</view>
</view>
<view v-if="smsRequired" class="field">
<text class="lab">双因素安全验证码</text>
<view class="box">
<input v-model="sms" placeholder="6位动态码" maxlength="8" />
<text class="sms" :class="{ off: wait > 0 }" @click="sendSms">{{ wait > 0 ? wait + 's 后重新获取' : '获取验证码' }}</text>
</view>
</view>
<view class="agree" @click="agreed = !agreed">
<view class="ck" :class="{ on: agreed }" />
<text class="ag">我已阅读并同意企业信息安全保密协议员工使用守则</text>
</view>
<view class="btn-primary" :class="{ dim: busy }" @click="submit">{{ busy ? '校验中' : '安全登录' }}</view>
<text v-if="error" class="err">{{ error }}</text>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store, savedPhone } from '../../utils/store.js'
import { sendLoginCode } from '../../utils/api.js'
import { toast } from '../../utils/format.js'
export default {
components: { FyHeader },
data() {
return {
phone: '',
password: '',
sms: '',
showPwd: false,
agreed: true,
wait: 0,
timer: 0,
busy: false,
error: '',
smsRequired: false,
off: null,
lastPhase: ''
}
},
onShow() {
if (this.off) this.off()
this.phone = this.phone || savedPhone()
this.smsRequired = store.smsRequired
if (store.fixedSms && !this.sms) this.sms = store.fixedSms
this.sync()
this.off = store.on(() => this.sync())
},
onHide() {
if (this.off) this.off()
this.off = null
},
onUnload() {
if (this.timer) clearInterval(this.timer)
if (this.off) this.off()
this.off = null
},
methods: {
sync() {
this.busy = store.busy
this.error = store.error
this.smsRequired = store.smsRequired
if (store.phase === this.lastPhase) return
this.lastPhase = store.phase
if (store.phase === 'forcePassword') {
uni.redirectTo({ url: '/pages/password/password' })
} else if (store.phase === 'app') {
uni.switchTab({ url: '/pages/tabs/messages' })
}
},
async sendSms() {
if (this.wait > 0) return
if (!this.phone.trim()) return toast('请先填写手机号或工号')
try {
await sendLoginCode(this.phone.trim())
toast('验证码已发送', 'success')
this.wait = 60
this.timer = setInterval(() => {
this.wait -= 1
if (this.wait <= 0) clearInterval(this.timer)
}, 1000)
} catch (e) {
toast(e.message || '发送失败')
}
},
async submit() {
if (!this.agreed) return toast('请先阅读并勾选企业安全协议')
if (!this.phone.trim() || !this.password) return toast('请填写账号和密码')
await store.login({ phone: this.phone, password: this.password, sms: this.sms })
this.sync()
}
}
}
</script>
<style scoped>
.login { padding-bottom: 48rpx; }
.body { padding: 48rpx 40rpx 80rpx; }
.brand-box { display: flex; flex-direction: column; align-items: center; margin-bottom: 48rpx; }
.logo {
width: 128rpx; height: 128rpx; border-radius: 32rpx;
background: linear-gradient(135deg, #004ac6, #2563eb);
color: #fff; font-size: 52rpx; font-weight: 700;
display: flex; align-items: center; justify-content: center;
margin-bottom: 20rpx;
}
.name { font-size: 40rpx; font-weight: 700; }
.sub { font-size: 22rpx; color: #737686; margin-top: 8rpx; }
.field { margin-bottom: 24rpx; }
.lab { font-size: 24rpx; color: #434655; margin-bottom: 8rpx; display: block; }
.box {
height: 96rpx; background: #fff; border-radius: 16rpx;
border: 1rpx solid rgba(0,0,0,0.04); padding: 0 24rpx;
display: flex; align-items: center;
}
.box input { flex: 1; font-size: 28rpx; }
.pre { font-size: 24rpx; color: #737686; margin-right: 16rpx; padding-right: 16rpx; border-right: 1rpx solid #eaedff; }
.eye, .sms { font-size: 22rpx; color: #2563eb; padding-left: 12rpx; }
.sms.off { color: #737686; }
.agree { display: flex; align-items: flex-start; gap: 12rpx; margin: 16rpx 0 32rpx; }
.ck { width: 28rpx; height: 28rpx; border: 2rpx solid #c3c6d7; border-radius: 6rpx; margin-top: 4rpx; }
.ck.on { background: #2563eb; border-color: #2563eb; }
.ag { flex: 1; font-size: 22rpx; color: #737686; line-height: 1.5; }
.err { display: block; color: #ba1a1a; font-size: 24rpx; margin-top: 16rpx; text-align: center; }
.dim { opacity: 0.65; }
</style>
+46
View File
@@ -0,0 +1,46 @@
<template>
<view class="page">
<fy-header title="我的资料" back />
<view class="card">
<view v-for="r in rows" :key="r.k" class="row">
<text class="k">{{ r.k }}</text>
<text class="v">{{ r.v }}</text>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { oaGet } from '../../utils/api.js'
export default {
components: { FyHeader },
data() { return { profile: {} } },
computed: {
rows() {
const m = store.me || {}
const p = this.profile || {}
return [
{ k: '姓名', v: p.realName || m.name },
{ k: '手机', v: p.phone || m.phone },
{ k: '部门', v: p.dept || m.department },
{ k: '职务', v: p.role || m.title },
{ k: '工号', v: p.jobNo || m.jobId || '-' },
{ k: '状态', v: p.employeeStatus || m.employeeStatus || '在职' }
]
}
},
onShow() {
oaGet('/profile', store.token).then((d) => { this.profile = d || {} }).catch(() => {})
}
}
</script>
<style scoped>
.card { margin: 24rpx; overflow: hidden; }
.row { display: flex; justify-content: space-between; padding: 28rpx 24rpx; border-bottom: 1rpx solid #eaedff; }
.k { color: #737686; font-size: 26rpx; }
.v { font-size: 26rpx; }
</style>
+32
View File
@@ -0,0 +1,32 @@
<template>
<view class="page">
<fy-header title="工资条" back />
<view v-if="!rows.length" class="empty">暂无工资条</view>
<view v-for="r in rows" :key="r.id || r.month" class="card item">
<text class="t">{{ r.month || r.period || '工资条' }}</text>
<text class="s">应发 {{ r.gross || r.shouldPay || '-' }} · 实发 {{ r.net || r.actual || '-' }}</text>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { asList } from '../../utils/format.js'
import { oaGet } from '../../utils/api.js'
export default {
components: { FyHeader },
data() { return { rows: [] } },
onShow() {
oaGet('/profile/payslips', store.token).then((d) => { this.rows = asList(d) }).catch(() => { this.rows = [] })
}
}
</script>
<style scoped>
.item { margin: 16rpx 24rpx; padding: 24rpx; }
.t { display: block; font-weight: 600; }
.s { font-size: 24rpx; color: #737686; }
.empty { padding: 80rpx; text-align: center; color: #737686; }
</style>
+32
View File
@@ -0,0 +1,32 @@
<template>
<view class="page">
<fy-header title="我的绩效" back />
<view v-if="!rows.length" class="empty">暂无绩效记录</view>
<view v-for="r in rows" :key="r.id || r.period" class="card item">
<text class="t">{{ r.period || r.quarter || '绩效' }}</text>
<text class="s">评级 {{ r.grade || r.level || '-' }} · {{ r.score || '' }}</text>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { asList } from '../../utils/format.js'
import { oaGet } from '../../utils/api.js'
export default {
components: { FyHeader },
data() { return { rows: [] } },
onShow() {
oaGet('/profile/performance', store.token).then((d) => { this.rows = asList(d) }).catch(() => { this.rows = [] })
}
}
</script>
<style scoped>
.item { margin: 16rpx 24rpx; padding: 24rpx; }
.t { display: block; font-weight: 600; }
.s { font-size: 24rpx; color: #737686; }
.empty { padding: 80rpx; text-align: center; color: #737686; }
</style>
+34
View File
@@ -0,0 +1,34 @@
<template>
<view class="page">
<fy-header title="公告" back />
<view v-if="!rows.length" class="empty">暂无公告</view>
<view v-for="r in rows" :key="r.id" class="card item">
<text class="t">{{ r.title }}</text>
<text class="s">{{ r.createdAt || r.publishAt || '' }}</text>
<text class="c">{{ r.content || r.summary || '' }}</text>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { asList } from '../../utils/format.js'
import { oaGet } from '../../utils/api.js'
export default {
components: { FyHeader },
data() { return { rows: [] } },
onShow() {
oaGet('/announcements', store.token).then((d) => { this.rows = asList(d) }).catch(() => { this.rows = store.announcements || [] })
}
}
</script>
<style scoped>
.item { margin: 16rpx 24rpx; padding: 24rpx; }
.t { display: block; font-weight: 600; font-size: 30rpx; }
.s { font-size: 22rpx; color: #737686; }
.c { display: block; font-size: 26rpx; margin-top: 8rpx; }
.empty { padding: 80rpx; text-align: center; color: #737686; }
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<view class="page">
<fy-header title="修改密码" />
<view class="body">
<text class="hint">首次登录或管理员要求改密请设置新密码后继续</text>
<view class="field">
<text class="lab">新密码</text>
<view class="box"><input v-model="next" password placeholder="至少 6 位" /></view>
</view>
<view class="field">
<text class="lab">确认新密码</text>
<view class="box"><input v-model="confirm" password placeholder="再输入一次" /></view>
</view>
<view class="btn-primary" @click="ok">确认修改</view>
<text v-if="error" class="err">{{ error }}</text>
</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 { next: '', confirm: '', error: '', off: null, left: false }
},
onShow() {
if (this.off) this.off()
this.off = store.on(() => {
this.error = store.error
if (this.left || store.phase !== 'app') return
this.left = true
uni.switchTab({ url: '/pages/tabs/messages' })
})
},
onHide() {
if (this.off) this.off()
this.off = null
},
methods: {
async ok() {
if (!this.next || this.next.length < 6) return toast('密码至少 6 位')
if (this.next !== this.confirm) return toast('两次密码不一致')
await store.changePassword(this.next, this.confirm)
}
}
}
</script>
<style scoped>
.body { padding: 40rpx; }
.hint { font-size: 26rpx; color: #737686; display: block; margin-bottom: 32rpx; }
.field { margin-bottom: 24rpx; }
.lab { font-size: 24rpx; color: #434655; display: block; margin-bottom: 8rpx; }
.box { height: 96rpx; background: #fff; border-radius: 16rpx; padding: 0 24rpx; display: flex; align-items: center; }
.box input { flex: 1; }
.err { color: #ba1a1a; font-size: 24rpx; margin-top: 16rpx; display: block; }
</style>
+36
View File
@@ -0,0 +1,36 @@
<template>
<view class="page">
<fy-header title="附件预览" back />
<view class="body">
<text class="name">{{ name || '附件' }}</text>
<view class="btn-primary" @click="open">打开 / 预览</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { openFile } from '../../utils/files.js'
export default {
components: { FyHeader },
data() { return { id: '', name: '' } },
onLoad(q) {
this.id = decodeURIComponent(q.id || '')
this.name = decodeURIComponent(q.name || '附件')
this.open()
},
methods: {
open() {
if (!this.id) return
openFile({ id: this.id, name: this.name }, store.token)
}
}
}
</script>
<style scoped>
.body { padding: 48rpx 32rpx; }
.name { display: block; font-size: 28rpx; margin-bottom: 32rpx; text-align: center; }
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<view class="page">
<fy-header title="工作汇报" back />
<view v-if="!can" class="deny">当前账号没有写汇报权限</view>
<view v-else>
<view class="card form">
<picker :range="kinds" @change="(e) => kind = kinds[e.detail.value]">
<view class="box">{{ kind }}</view>
</picker>
<view class="box tall"><textarea v-model="content" placeholder="今日工作内容 / 周报摘要" /></view>
<view class="btn-primary" @click="submit">提交汇报</view>
</view>
<view v-for="r in rows" :key="r.id" class="card item">
<text class="t">{{ r.title || r.kind || '汇报' }}</text>
<text class="s">{{ r.createdAt || r.date || '' }}</text>
<text class="c">{{ r.content || r.summary || '' }}</text>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { asList, toast } from '../../utils/format.js'
import { oaGet, oaPost } from '../../utils/api.js'
export default {
components: { FyHeader },
data() {
return { kinds: ['日报', '周报', '月报', '工时'], kind: '日报', content: '', rows: [] }
},
computed: {
can() { return store.can('personal', 'report') }
},
onShow() { if (this.can) this.load() },
methods: {
async load() {
try {
this.rows = asList(await oaGet('/reports?mine=1', store.token))
} catch (_) {
try { this.rows = asList(await oaGet('/project/hours?mine=1', store.token)) } catch (e) { this.rows = [] }
}
},
async submit() {
if (!this.content.trim()) return toast('请填写内容')
try {
await oaPost('/reports', { kind: this.kind, content: this.content, title: this.kind }, store.token)
toast('已提交', 'success')
this.content = ''
this.load()
} catch (e) {
toast(e.message || '提交失败')
}
}
}
}
</script>
<style scoped>
.form { margin: 24rpx; padding: 24rpx; }
.box { background: #f2f3ff; border-radius: 12rpx; padding: 20rpx; margin-bottom: 16rpx; }
.box.tall { min-height: 160rpx; }
.item { margin: 0 24rpx 16rpx; padding: 24rpx; }
.t { display: block; font-weight: 600; }
.s { font-size: 22rpx; color: #737686; }
.c { display: block; font-size: 26rpx; margin-top: 8rpx; }
.deny { padding: 80rpx; text-align: center; color: #737686; }
</style>
+110
View File
@@ -0,0 +1,110 @@
<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>
<view v-if="people.length" class="sec">同事</view>
<view v-for="p in people" :key="'p' + p.id" class="item" @click="openPerson(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>
<view class="meta">
<text class="n">{{ p.name }}</text>
<text class="s">{{ p.department }} {{ p.title }}</text>
</view>
</view>
<view v-if="convs.length" class="sec">会话</view>
<view v-for="c in convs" :key="'c' + c.id" class="item" @click="openConv(c)">
<view class="av">{{ (c.name || '?').slice(0, 1) }}</view>
<view class="meta">
<text class="n">{{ c.name }}</text>
<text class="s">{{ c.lastMessage }}</text>
</view>
</view>
<view v-if="hits.length" class="sec">聊天记录</view>
<view v-for="h in hits" :key="h.id" class="item" @click="openConv(h.conv)">
<view class="av">{{ (h.conv.name || '?').slice(0, 1) }}</view>
<view class="meta">
<text class="n">{{ h.conv.name }}</text>
<text class="s">{{ h.text }}</text>
</view>
</view>
<view v-if="todos.length" class="sec">待办</view>
<view v-for="t in todos" :key="t.no || t.id" class="item" @click="openTodo(t)">
<view class="av work"></view>
<view class="meta">
<text class="n">{{ t.title || t.no }}</text>
</view>
</view>
<view v-if="!people.length && !convs.length && !hits.length && !todos.length" class="empty">没有找到结果</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store, previewOf } from '../../utils/store.js'
export default {
components: { FyHeader },
data() { return { q: '' } },
computed: {
people() {
const q = this.q.trim()
if (!q) return []
return Object.values(store.people).filter((p) => (p.name + p.phone + p.department).includes(q)).slice(0, 8)
},
convs() {
const q = this.q.trim()
if (!q) return []
return store.conversations.filter((c) => (c.name + (c.lastMessage || '')).includes(q)).slice(0, 8)
},
hits() {
const q = this.q.trim()
if (!q) return []
const out = []
store.conversations.forEach((c) => {
(c.messages || []).forEach((m) => {
if (m.recalled) return
const text = previewOf(m)
if (text.indexOf(q) >= 0) out.push({ id: c.id + ':' + m.id, conv: c, text })
})
})
return out.slice(0, 12)
},
todos() {
const q = this.q.trim()
if (!q) return []
return (store.todos || []).filter((t) => String(t.title || t.no || '').includes(q)).slice(0, 8)
}
},
methods: {
openPerson(p) {
uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(p.id) })
},
openConv(c) {
if (c.type === 'work') uni.navigateTo({ url: '/pages/chat/work?sid=' + encodeURIComponent(c.id) })
else uni.navigateTo({ url: '/pages/chat/chat?sid=' + encodeURIComponent(c.id) })
},
openTodo(t) {
uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(t.no || t.id) })
}
}
}
</script>
<style scoped>
.search { padding: 16rpx 24rpx; }
.search input { height: 72rpx; background: #ededed; border-radius: 8rpx; padding: 0 24rpx; }
.sec { padding: 20rpx 24rpx 8rpx; font-size: 22rpx; color: #888; background: #f7f7f7; }
.item { display: flex; align-items: center; padding: 20rpx 24rpx; background: #fff; }
.av { width: 72rpx; height: 72rpx; 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: 72rpx; height: 72rpx; }
.av.work { background: #2563eb; color: #fff; }
.meta { margin-left: 16rpx; min-width: 0; }
.n { display: block; font-size: 28rpx; }
.s { font-size: 22rpx; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { text-align: center; color: #888; padding: 80rpx; font-size: 26rpx; }
</style>
+124
View File
@@ -0,0 +1,124 @@
<template>
<view class="page">
<fy-header title="打卡" />
<view v-if="!canPunch" class="deny">当前账号没有打卡权限</view>
<view v-else class="body">
<view class="clock-box">
<text class="now">{{ now }}</text>
<text class="shift">班次 {{ rules.workStart }}{{ rules.workEnd }}{{ locName ? ' · ' + locName : '' }}</text>
</view>
<view class="grid">
<view class="stat card">
<text class="lab">上班</text>
<text class="val" :class="{ ok: inRec }">{{ inRec ? inRec.time + ' 已打卡' : '未打卡' }}</text>
<text class="tiny">{{ inRec ? (inRec.status || '正常') : '待打卡' }}</text>
</view>
<view class="stat card">
<text class="lab">下班</text>
<text class="val" :class="{ ok: outRec }">{{ outRec ? outRec.time + ' 已打卡' : '未打卡' }}</text>
<text class="tiny">{{ outRec ? (outRec.status || '正常') : '待打卡' }}</text>
</view>
</view>
<view class="btn-primary" @click="doPunch">{{ punching ? '定位中' : (outRec ? '更新打卡' : '打卡') }}</view>
<text class="geo">将使用手机定位校验打卡范围</text>
<view class="quick">
<text class="q" @click="goKind('card')">补卡</text>
<text class="q" @click="goKind('leave')">请假</text>
<text class="q" @click="goKind('overtime')">加班</text>
<text class="q" @click="goKind('outing')">外出</text>
</view>
<view class="sec">
<text class="st">今日及近期记录</text>
</view>
<view class="card">
<view v-if="!records.length" class="empty">暂无记录</view>
<view v-for="r in records" :key="r.id || r.time" class="rec">
<view>
<text class="rt">{{ r.title || r.kind || '打卡' }} {{ r.time || r.punchTime || '' }}</text>
<text class="rl">{{ r.location || r.address || '' }}</text>
</view>
<text class="ok">{{ r.status || '正常' }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { clockText, toast } from '../../utils/format.js'
export default {
components: { FyHeader },
data() {
return { now: clockText(), timer: 0, punching: false, off: null }
},
computed: {
canPunch() { return store.can('personal', 'punch') },
rules() { return store.attendRules },
records() { return store.attendance || [] },
locName() {
const locs = this.rules.locations || []
return locs[0] ? (locs[0].name || locs[0].title || '') : ''
},
inRec() {
return this.records.find((r) => /上班|in|on/i.test(String(r.title || r.kind || r.type || '')))
},
outRec() {
return this.records.find((r) => /下班|out/i.test(String(r.title || r.kind || r.type || '')))
}
},
onShow() {
if (store.phase !== 'app') uni.reLaunch({ url: '/pages/login/login' })
store.refreshAttendance()
this.timer = setInterval(() => { this.now = clockText() }, 1000)
this.off = store.on(() => this.$forceUpdate())
},
onHide() {
if (this.timer) clearInterval(this.timer)
if (this.off) this.off()
},
methods: {
async doPunch() {
if (this.punching) return
this.punching = true
try {
const msg = await store.punch()
toast(msg, 'success')
} catch (e) {
toast(e.message || '打卡失败')
} finally {
this.punching = false
}
},
goKind(kind) {
if (!store.can('personal', 'apply')) return toast('没有发起申请权限')
uni.navigateTo({ url: '/pages/apply/form?kind=' + kind })
}
}
}
</script>
<style scoped>
.body { padding: 16rpx 24rpx 40rpx; }
.clock-box { background: #f2f3ff; border-radius: 16rpx; padding: 36rpx 0; text-align: center; }
.now { display: block; font-size: 68rpx; font-weight: 700; letter-spacing: 2rpx; }
.shift { font-size: 22rpx; color: #434655; }
.grid { display: flex; gap: 16rpx; margin: 20rpx 0; }
.stat { flex: 1; padding: 24rpx; }
.lab { font-size: 26rpx; font-weight: 600; display: block; }
.val { font-size: 24rpx; color: #434655; display: block; margin-top: 12rpx; }
.val.ok { color: #006c49; font-weight: 700; }
.tiny { font-size: 20rpx; color: #737686; }
.geo { display: block; text-align: center; font-size: 22rpx; color: #434655; margin: 16rpx 0 24rpx; }
.quick { display: flex; gap: 12rpx; }
.q { flex: 1; height: 72rpx; background: #fff; border-radius: 12rpx; text-align: center; line-height: 72rpx; font-size: 24rpx; }
.sec { margin: 32rpx 0 12rpx; font-size: 28rpx; font-weight: 600; }
.rec { display: flex; justify-content: space-between; align-items: center; padding: 24rpx 28rpx; border-bottom: 1rpx solid #eaedff; }
.rt { display: block; font-size: 28rpx; font-weight: 600; }
.rl { display: block; font-size: 22rpx; color: #737686; }
.ok { color: #006c49; font-size: 24rpx; }
.empty { padding: 40rpx; text-align: center; color: #737686; }
.deny { padding: 120rpx 40rpx; text-align: center; color: #737686; }
</style>
+157
View File
@@ -0,0 +1,157 @@
<template>
<view class="page">
<fy-header title="通讯录">
<template #right>
<text class="ico" @click="goGroup">建群</text>
</template>
</fy-header>
<view class="search">
<input v-model="q" placeholder="搜索同事 / 部门 / 手机号" />
</view>
<view class="org" @click="goGroups">
<view class="obox">👥</view>
<view class="ometa">
<text class="on">群聊</text>
<text class="os">{{ groupCount }} 个群</text>
</view>
<text class="arr"></text>
</view>
<view class="org" @click="goSearch">
<view class="obox">🏛</view>
<view class="ometa">
<text class="on">全员架构</text>
<text class="os"> {{ deptCount }} 个部门 · {{ peopleCount }} 位在职成员</text>
</view>
<text class="arr"></text>
</view>
<view v-for="g in groups" :key="g.name" class="block">
<view class="dept" @click="toggle(g.name)">
<text>{{ g.name }}</text>
<text class="cnt">({{ g.people.length }})</text>
</view>
<view v-if="open[g.name] !== false" class="card">
<view v-for="p in g.people" :key="p.id" class="item" @click="openPerson(p)">
<view class="av-wrap">
<image v-if="p.avatar" class="av" :src="p.avatar" mode="aspectFill" />
<view v-else class="av txt">{{ p.name.slice(0, 1) }}</view>
<view v-if="p.online" class="dot" />
</view>
<view class="meta">
<text class="name">{{ p.name }}</text>
<text class="sub">{{ p.online ? '在线' : '离线' }} · {{ p.title }}</text>
</view>
<text class="chip">{{ p.title }}</text>
<text class="mini" @click.stop="chat(p)">💬</text>
<text class="mini" @click.stop="call(p)"></text>
</view>
</view>
</view>
<view v-if="!groups.length" class="empty">没有匹配的同事</view>
<text class="foot">仅显示当前所属企业成员 · 数据实时同步</text>
</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 { q: '', open: {}, off: null }
},
computed: {
peopleCount() {
return Object.keys(store.people).length
},
deptCount() {
return new Set(Object.values(store.people).map((p) => p.department || '未分组')).size
},
groupCount() {
return store.conversations.filter((c) => c.type === 'group').length
},
groups() {
const q = this.q.trim()
const list = Object.values(store.people).filter((p) => {
if (store.me && p.id === store.me.id) return false
if (!q) return true
return (p.name + p.department + p.phone + p.title).includes(q)
})
const map = {}
list.forEach((p) => {
const d = p.department || '未分组'
if (!map[d]) map[d] = []
map[d].push(p)
})
return Object.keys(map).sort().map((name) => ({ name, people: map[name] }))
}
},
onShow() {
if (store.phase !== 'app') uni.reLaunch({ url: '/pages/login/login' })
if (this.off) this.off()
store.refreshPeoplePhotos()
this.off = store.on(() => this.$forceUpdate())
},
onHide() { if (this.off) this.off() },
methods: {
toggle(name) {
this.open[name] = this.open[name] === false
},
openPerson(p) {
uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(p.id) })
},
chat(p) {
const conv = store.openDirect(p)
uni.navigateTo({ url: '/pages/chat/chat?sid=' + encodeURIComponent(conv.id) })
},
call(p) {
if (!p.phone) return toast('该同事未登记手机号')
uni.makePhoneCall({ phoneNumber: String(p.phone) })
},
goSearch() { uni.navigateTo({ url: '/pages/search/search' }) },
goGroup() { uni.navigateTo({ url: '/pages/chat/create' }) },
goGroups() {
const groups = store.conversations.filter((c) => c.type === 'group')
if (!groups.length) {
uni.navigateTo({ url: '/pages/chat/create' })
return
}
uni.showActionSheet({
itemList: groups.slice(0, 6).map((c) => c.name || '群聊'),
success: (res) => {
const c = groups[res.tapIndex]
if (c) uni.navigateTo({ url: '/pages/chat/chat?sid=' + encodeURIComponent(c.id) })
}
})
}
}
}
</script>
<style scoped>
.search { padding: 16rpx 24rpx; }
.search input { height: 80rpx; background: #fff; border-radius: 12rpx; padding: 0 24rpx; font-size: 26rpx; }
.org { margin: 0 24rpx 16rpx; background: #fff; border-radius: 16rpx; padding: 20rpx; display: flex; align-items: center; }
.obox { width: 64rpx; height: 64rpx; border-radius: 12rpx; background: #eaedff; display: flex; align-items: center; justify-content: center; }
.ometa { flex: 1; margin-left: 16rpx; }
.on { display: block; font-size: 28rpx; font-weight: 700; }
.os { font-size: 22rpx; color: #737686; }
.arr { color: #c3c6d7; }
.block { margin: 8rpx 24rpx 24rpx; }
.dept { display: flex; align-items: center; padding: 16rpx 12rpx; font-size: 26rpx; font-weight: 600; background: #f2f3ff; border-radius: 12rpx 12rpx 0 0; }
.cnt { color: #737686; font-weight: 400; font-size: 22rpx; margin-left: 8rpx; }
.item { display: flex; align-items: center; padding: 20rpx 20rpx; }
.av-wrap { position: relative; width: 72rpx; height: 72rpx; }
.av { width: 72rpx; height: 72rpx; border-radius: 50%; }
.av.txt { background: #eaedff; color: #434655; display: flex; align-items: center; justify-content: center; font-weight: 600; }
.dot { position: absolute; right: 0; bottom: 0; width: 16rpx; height: 16rpx; border-radius: 50%; background: #006c49; border: 2rpx solid #fff; }
.meta { flex: 1; margin-left: 16rpx; min-width: 0; }
.name { display: block; font-size: 28rpx; font-weight: 600; }
.sub { font-size: 22rpx; color: #737686; }
.chip { font-size: 20rpx; color: #004ac6; background: #e2e7ff; padding: 4rpx 12rpx; border-radius: 999rpx; margin-right: 8rpx; }
.mini { width: 56rpx; text-align: center; font-size: 28rpx; color: #004ac6; }
.empty { text-align: center; color: #737686; padding: 80rpx; }
.foot { display: block; text-align: center; color: #737686; font-size: 22rpx; padding: 20rpx 0 40rpx; }
.ico { padding: 8rpx 16rpx; color: #2563eb; font-size: 26rpx; }
</style>
+134
View File
@@ -0,0 +1,134 @@
<template>
<view class="page">
<fy-header title="我" />
<view class="body">
<view class="card profile">
<view class="row">
<image v-if="me && me.avatar" class="av" :src="me.avatar" mode="aspectFill" />
<view v-else class="av txt">{{ initial }}</view>
<view class="info">
<view class="nm">
<text class="name">{{ me ? me.name : '' }}</text>
<text class="st">在职</text>
</view>
<text class="dept">{{ me ? (me.department + ' · ' + me.title) : '' }}</text>
<text class="ph">{{ phone }}{{ job }}</text>
</view>
</view>
<view class="stats">
<view class="s" @click="switchClock">
<text class="n">{{ attendDays }}</text>
<text class="l">本月出勤()</text>
</view>
<view class="s mid" @click="goTodo">
<text class="n">{{ todoCount }}</text>
<text class="l">待办申请()</text>
</view>
<view class="s">
<text class="n ok">正常</text>
<text class="l">考勤状态</text>
</view>
</view>
</view>
<view class="card menu">
<view v-for="a in menus" :key="a.id" class="mi" @click="open(a)">
<text class="ii">{{ a.icon }}</text>
<text class="it">{{ a.title }}</text>
<text class="arr"></text>
</view>
</view>
<view class="card menu">
<view class="mi" @click="goPwd">
<text class="ii">🔑</text>
<text class="it">修改密码</text>
<text class="arr"></text>
</view>
<view class="mi danger" @click="logout">
<text class="ii"></text>
<text class="it">退出登录</text>
</view>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { maskPhone } from '../../utils/format.js'
const ME_IDS = ['archive', 'salary', 'perf', 'expense', 'report', 'apply']
export default {
components: { FyHeader },
data() {
return { off: null }
},
computed: {
me() { return store.me },
initial() { return this.me ? this.me.name.slice(0, 1) : '我' },
phone() { return maskPhone(this.me && this.me.phone) },
job() { return this.me && this.me.jobId ? ' · 工号: ' + this.me.jobId : '' },
todoCount() { return store.todoCount || 0 },
attendDays() { return (store.attendance || []).length || 0 },
menus() {
return store.personalApps().filter((a) => ME_IDS.includes(a.id))
}
},
onShow() {
if (store.phase !== 'app') uni.reLaunch({ url: '/pages/login/login' })
this.off = store.on(() => this.$forceUpdate())
},
onHide() { if (this.off) this.off() },
methods: {
open(a) {
if (a.url.startsWith('/pages/tabs/')) uni.switchTab({ url: a.url.split('?')[0] })
else uni.navigateTo({ url: a.url })
},
switchClock() { uni.switchTab({ url: '/pages/tabs/clock' }) },
goTodo() {
if (store.can('personal', 'approve')) uni.navigateTo({ url: '/pages/todo/list' })
},
goPwd() { uni.navigateTo({ url: '/pages/password/password' }) },
logout() {
uni.showModal({
title: '退出登录',
content: '确定退出当前账号?',
success: (r) => {
if (r.confirm) {
store.logout()
uni.reLaunch({ url: '/pages/login/login' })
}
}
})
}
}
}
</script>
<style scoped>
.body { padding: 16rpx 24rpx 40rpx; }
.profile { padding: 28rpx; }
.row { display: flex; align-items: center; }
.av { width: 112rpx; height: 112rpx; border-radius: 50%; }
.av.txt { background: #004ac6; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 40rpx; font-weight: 700; }
.info { margin-left: 20rpx; flex: 1; min-width: 0; }
.nm { display: flex; align-items: center; gap: 12rpx; }
.name { font-size: 34rpx; font-weight: 700; }
.st { font-size: 20rpx; background: rgba(108, 248, 187, 0.3); color: #006c49; padding: 2rpx 12rpx; border-radius: 999rpx; }
.dept, .ph { display: block; font-size: 22rpx; color: #737686; margin-top: 6rpx; }
.stats { display: flex; margin-top: 24rpx; padding-top: 20rpx; border-top: 1rpx solid #eaedff; }
.s { flex: 1; text-align: center; }
.mid { border-left: 1rpx solid #eaedff; border-right: 1rpx solid #eaedff; }
.n { display: block; font-size: 36rpx; font-weight: 700; color: #004ac6; }
.n.ok { color: #006c49; font-size: 30rpx; }
.l { font-size: 20rpx; color: #737686; }
.menu { margin-top: 20rpx; overflow: hidden; }
.mi { display: flex; align-items: center; padding: 28rpx 24rpx; border-bottom: 1rpx solid #eaedff; }
.ii { width: 48rpx; }
.it { flex: 1; font-size: 28rpx; }
.arr { color: #c3c6d7; }
.danger .it { color: #ba1a1a; }
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<view class="page msg">
<fy-header :title="unread ? ('消息(' + unread + ')') : '消息'">
<template #right>
<text class="ico sm" @click="goSearch"></text>
<text class="ico" @click="plus"></text>
</template>
</fy-header>
<scroll-view scroll-y class="list">
<view v-if="!rows.length" class="empty">暂无会话</view>
<view
v-for="c in rows"
:key="c.id"
class="item"
:class="{ pin: c.pinned }"
@click="open(c)"
@longpress="act(c)"
>
<view class="av-wrap">
<image v-if="c.avatar" class="av" :src="c.avatar" mode="aspectFill" />
<view v-else class="av txt" :class="avClass(c)">{{ avText(c) }}</view>
<text v-if="c.unread && !c.muted" class="badge abs">{{ c.unread > 99 ? '99+' : c.unread }}</text>
<text v-else-if="c.unread && c.muted" class="dotu" />
</view>
<view class="meta">
<view class="top">
<text class="name">{{ c.name }}</text>
<text class="time">{{ c.lastTime }}</text>
</view>
<view class="bot">
<text class="prev">{{ c.muted && c.unread ? '[' + c.unread + '条]' : '' }}{{ c.lastMessage || ' ' }}</text>
<text v-if="c.muted" class="mute">🔕</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
export default {
components: { FyHeader },
data() {
return { rows: [], unread: 0, off: null }
},
onShow() {
if (this.off) this.off()
this.guard()
store.refreshPeoplePhotos()
this.pull()
this.off = store.on(() => this.pull())
},
onHide() {
if (this.off) this.off()
this.off = null
},
methods: {
guard() {
if (store.phase === 'login') uni.reLaunch({ url: '/pages/login/login' })
if (store.phase === 'forcePassword') uni.redirectTo({ url: '/pages/password/password' })
},
pull() {
this.rows = store.conversations.slice()
this.unread = store.unreadTotal()
},
avText(c) {
if (c.type === 'work') return '工'
if (c.type === 'files') return '文'
if (c.type === 'group') return '群'
return (c.name || '?').slice(0, 1)
},
avClass(c) {
if (c.type === 'work') return 'work'
if (c.type === 'files') return 'file'
return 'plain'
},
open(c) {
if (c.type === 'work') {
uni.navigateTo({ url: '/pages/chat/work?sid=' + encodeURIComponent(c.id) })
return
}
uni.navigateTo({ url: '/pages/chat/chat?sid=' + encodeURIComponent(c.id) })
},
act(c) {
const items = [
c.pinned ? '取消置顶' : '置顶聊天',
c.unread ? '标为已读' : '标为未读',
c.muted ? '关闭免打扰' : '消息免打扰',
'删除该聊天'
]
uni.showActionSheet({
itemList: items,
success: (res) => {
if (res.tapIndex === 0) store.setSession(c, { pinned: !c.pinned })
else if (res.tapIndex === 1) {
if (c.unread) {
c.unread = 0
store.emit()
} else store.markUnread(c)
} else if (res.tapIndex === 2) store.setSession(c, { muted: !c.muted })
else if (res.tapIndex === 3) {
uni.showModal({
title: '删除聊天',
content: '仅从消息列表移除,聊天记录仍保留在服务器。',
success: (r) => { if (r.confirm) store.hideConv(c) }
})
}
}
})
},
plus() {
uni.showActionSheet({
itemList: ['发起群聊', '添加同事'],
success: (res) => {
if (res.tapIndex === 0) uni.navigateTo({ url: '/pages/chat/create' })
else uni.navigateTo({ url: '/pages/search/search' })
}
})
},
goSearch() { uni.navigateTo({ url: '/pages/search/search' }) }
}
}
</script>
<style scoped>
.msg { display: flex; flex-direction: column; height: 100vh; background: #fff; }
.list { flex: 1; height: 0; }
.ico.sm { font-size: 36rpx; }
.item { display: flex; align-items: center; padding: 20rpx 24rpx; background: #fff; }
.item.pin { background: #f7f7f7; }
.av-wrap { position: relative; width: 96rpx; height: 96rpx; flex-shrink: 0; }
.av { width: 96rpx; height: 96rpx; border-radius: 12rpx; }
.av.txt { display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 600; }
.av.work { background: #2563eb; color: #fff; }
.av.file { background: #e2e7ff; color: #434655; }
.av.plain { background: #e2e7ff; color: #004ac6; }
.abs { position: absolute; top: -8rpx; right: -8rpx; }
.dotu { position: absolute; top: -4rpx; right: -4rpx; width: 16rpx; height: 16rpx; border-radius: 50%; background: #fa5151; }
.meta { flex: 1; margin-left: 20rpx; min-width: 0; padding: 4rpx 0; border-bottom: 1rpx solid #f0f0f0; }
.top { display: flex; justify-content: space-between; align-items: center; }
.name { font-size: 32rpx; font-weight: 500; max-width: 420rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.time { font-size: 22rpx; color: #b2b2b2; }
.bot { display: flex; align-items: center; margin-top: 8rpx; }
.prev { flex: 1; font-size: 24rpx; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mute { font-size: 22rpx; margin-left: 8rpx; }
.empty { padding: 120rpx; text-align: center; color: #888; font-size: 26rpx; }
.ico { font-size: 40rpx; color: #111; padding: 8rpx 16rpx; }
</style>
+216
View File
@@ -0,0 +1,216 @@
<template>
<view class="page">
<fy-header title="工作台">
<template #right>
<text class="ico" @click="goSearch"></text>
</template>
</fy-header>
<view class="body">
<view v-if="announce" class="notice" @click="openNotice">
<text class="nlab">公告 · {{ announce }}</text>
<text class="arr"></text>
</view>
<view class="card apps">
<view class="cap">
<text>常用业务入口</text>
<text class="cap-sub more" @click="goApply">全部流程 </text>
</view>
<view v-if="!flows.length" class="empty">暂无可用审批流请确认账号已开通申请权限</view>
<view class="grid">
<view v-for="a in flows" :key="a.key" class="app" @click="openFlow(a)">
<view class="icon"><text>{{ a.emoji }}</text></view>
<text class="al">{{ a.label }}</text>
<text v-if="a.desc" class="ad">{{ a.desc }}</text>
</view>
</view>
</view>
<view v-if="apps.length" class="card apps">
<view class="cap">
<text>个人办公</text>
<text class="cap-sub">按你的权限显示</text>
</view>
<view class="grid">
<view v-for="a in apps" :key="a.id" class="app" @click="open(a)">
<view class="icon">
<text>{{ a.icon }}</text>
<text v-if="a.badge === 'todo' && todoCount" class="badge abs">{{ todoCount }}</text>
</view>
<text class="al">{{ a.title }}</text>
</view>
</view>
</view>
<view v-if="erp.length" class="card mods">
<view class="cap">
<text>管理应用</text>
<text class="cap-sub">Web 授权后才会出现</text>
</view>
<view v-for="m in erp" :key="m.id" class="mod" @click="open(m)">
<text class="mi">{{ m.icon }}</text>
<text class="mt">{{ m.title }}</text>
<text class="arr"></text>
</view>
</view>
<view class="todo-head">
<view class="th">
<text>我的待办</text>
<text v-if="todoCount" class="badge">{{ todoCount }}</text>
</view>
<text class="more" @click="goTodo">查看全部 </text>
</view>
<view v-if="!pending.length" class="empty">暂无待办</view>
<view v-for="t in pending" :key="pendKey(t)" class="card todo" @click="openPending(t)">
<view class="av">{{ (t.submitter || t.applicant || '?').slice(0, 1) }}</view>
<view class="tm">
<text class="tn">{{ t.title || t.no }}</text>
<text class="ts">{{ t.tag || t.typeLabel || t.type || '流程审批' }} · {{ t.submitter || '' }}</text>
</view>
<text class="chip">{{ t.btn || '去办理' }}</text>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { toast } from '../../utils/format.js'
import { oaPost } from '../../utils/api.js'
import { visibleApplyKinds } from '../../utils/apply.js'
import { openHref, tileEmoji } from '../../utils/nav.js'
export default {
components: { FyHeader },
data() {
return { apps: [], erp: [], todos: [], pendingItems: [], shortcuts: [], todoCount: 0, announce: '', off: null }
},
computed: {
flows() {
const fromApi = (this.shortcuts || []).map((s, i) => ({
key: 'sc-' + (s.to || s.label || i),
label: s.label || s.title || '入口',
desc: s.desc || '',
to: s.to || s.path || s.url || '',
emoji: tileEmoji(s)
})).filter((s) => s.to)
if (fromApi.length) return fromApi
const me = store.me || {}
if (!store.can('personal', 'apply')) return []
return visibleApplyKinds({
employeeStatus: me.employeeStatus,
isSuper: me.isSuper,
role: me.title
}).map((k) => ({
key: 'ak-' + k.kind,
label: k.title,
desc: k.hint,
to: '/pages/apply/form?kind=' + k.kind,
emoji: tileEmoji({ id: k.kind, title: k.title })
}))
},
pending() {
const mixed = this.pendingItems && this.pendingItems.length ? this.pendingItems : this.todos
return (mixed || []).slice(0, 8)
}
},
onShow() {
if (this.off) this.off()
if (store.phase !== 'app') uni.reLaunch({ url: '/pages/login/login' })
this.pull()
store.refreshPerms().then(() => {
this.pull()
return store.refreshWorkbench()
}).then(() => this.pull())
this.off = store.on(() => this.pull())
},
onHide() {
if (this.off) this.off()
this.off = null
},
methods: {
pull() {
this.apps = store.personalApps()
this.erp = store.erpModules()
this.todos = store.todos || []
this.pendingItems = store.pendingItems || []
this.shortcuts = store.shortcuts || []
this.todoCount = store.todoCount || 0
const a = (store.announcements || [])[0]
this.announce = a ? (a.title || a.content || '') : ''
},
open(item) {
if (!item || !item.url) return
if (item.url.startsWith('/pages/tabs/')) {
uni.switchTab({ url: item.url.split('?')[0] })
return
}
uni.navigateTo({ url: item.url })
},
openFlow(a) {
oaPost('/dashboard/shortcuts/hit', { path: a.to }, store.token).catch(() => {})
if (!openHref(a.to)) toast('该入口暂未接入手机端')
},
goSearch() { uni.navigateTo({ url: '/pages/search/search' }) },
goApply() {
if (store.can('personal', 'apply')) uni.navigateTo({ url: '/pages/apply/list' })
else toast('没有发起申请权限')
},
goTodo() { uni.navigateTo({ url: '/pages/todo/list' }) },
pendKey(t) { return t.no || t.id || t.to || t.title },
openPending(t) {
if (t.source === 'work' || t.taskId) {
uni.navigateTo({ url: '/pages/coop/detail?id=' + encodeURIComponent(t.id || t.taskId) })
return
}
const no = t.no || t.requestNo || t.id
if (no && (t.type || t.typeLabel || t.source === 'workflow' || !t.to || String(t.to).startsWith('/todo'))) {
uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(no) })
return
}
if (openHref(t.to, t)) return
if (no) uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(no) })
},
openNotice() { uni.navigateTo({ url: '/pages/notice/list' }) }
}
}
</script>
<style scoped>
.body { padding: 16rpx 24rpx 40rpx; }
.notice {
background: rgba(219, 225, 255, 0.4);
border: 1rpx solid #dbe1ff;
border-radius: 16rpx;
padding: 20rpx 24rpx;
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20rpx;
}
.nlab { font-size: 24rpx; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.arr { color: #737686; }
.apps, .mods { padding: 24rpx; margin-bottom: 20rpx; }
.cap { display: flex; justify-content: space-between; align-items: center; font-size: 30rpx; font-weight: 600; margin-bottom: 16rpx; border-bottom: 1rpx solid #eaedff; padding-bottom: 16rpx; }
.cap-sub { font-size: 22rpx; color: #737686; font-weight: 400; }
.grid { display: flex; flex-wrap: wrap; }
.app { width: 25%; display: flex; flex-direction: column; align-items: center; padding: 16rpx 4rpx; box-sizing: border-box; }
.icon { width: 80rpx; height: 80rpx; border-radius: 20rpx; background: #f2f3ff; display: flex; align-items: center; justify-content: center; position: relative; margin-bottom: 8rpx; font-size: 32rpx; }
.abs { position: absolute; top: -8rpx; right: -8rpx; }
.al { font-size: 22rpx; text-align: center; }
.ad { font-size: 18rpx; color: #737686; text-align: center; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mod { display: flex; align-items: center; padding: 20rpx 0; border-top: 1rpx solid #eaedff; }
.mi { width: 48rpx; }
.mt { flex: 1; font-size: 28rpx; }
.todo-head { display: flex; justify-content: space-between; align-items: center; margin: 16rpx 8rpx; }
.th { display: flex; align-items: center; gap: 10rpx; font-size: 30rpx; font-weight: 600; }
.more { font-size: 22rpx; color: #434655; }
.todo { display: flex; align-items: center; padding: 24rpx; margin-bottom: 16rpx; }
.av { width: 80rpx; height: 80rpx; border-radius: 50%; background: #dbe1ff; color: #004ac6; display: flex; align-items: center; justify-content: center; font-weight: 700; }
.tm { flex: 1; margin: 0 16rpx; min-width: 0; }
.tn { display: block; font-size: 28rpx; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ts { font-size: 22rpx; color: #737686; }
.empty { padding: 24rpx; text-align: center; color: #737686; font-size: 24rpx; }
.ico { padding: 8rpx 16rpx; }
</style>
+174
View File
@@ -0,0 +1,174 @@
<template>
<view class="page">
<fy-header title="审批详情" back />
<view v-if="row" class="body">
<view class="card">
<text class="t">{{ row.title || row.no }}</text>
<text class="s">{{ row.typeLabel || row.type }} · {{ row.submitter || '' }}</text>
<text class="s">单号 {{ row.no }}</text>
<text v-if="row.node" class="s">当前节点 {{ row.node }}</text>
<text v-if="row.amount != null && row.amount !== ''" class="amt">¥ {{ row.amount }}</text>
</view>
<view v-if="fields.length" class="card block">
<text class="h">申请内容</text>
<view v-for="f in fields" :key="f.label" class="kv">
<text class="k">{{ f.label }}</text>
<text class="v">{{ f.value }}</text>
</view>
</view>
<view v-if="invoices.length" class="card block">
<text class="h">发票明细 · {{ invoices.length }} </text>
<view v-for="(inv, i) in invoices" :key="inv.seq || i" class="inv">
<text class="tn">发票 {{ inv.seq || i + 1 }} · {{ inv.ticketType || '未选票种' }}</text>
<text class="s">金额 ¥{{ inv.amount || 0 }} {{ inv.invoiceNo ? '· 票号 ' + inv.invoiceNo : '' }} {{ inv.date || '' }}</text>
<text v-if="inv.note" class="s">{{ inv.note }}</text>
<fy-files :files="inv.files || []" label="" />
</view>
</view>
<view v-if="files.length" class="card block">
<fy-files :files="files" label="附件(点击预览)" />
</view>
<view v-if="logs.length" class="card block">
<text class="h">流转轨迹</text>
<view v-for="(log, i) in logs" :key="i" class="log">
<text class="who">{{ log.who || log.actor || log.userName }} · {{ log.action }}</text>
<text v-if="log.comment" class="s">{{ log.comment }}</text>
<text class="s">{{ log.label || log.time || log.at || '' }}</text>
</view>
</view>
<view v-if="canAct" class="acts">
<view class="field">
<text class="lab">审批意见</text>
<view class="box"><textarea v-model="comment" placeholder="选填" /></view>
</view>
<view class="btns">
<view class="btn no" @click="audit('reject')">驳回</view>
<view class="btn-primary yes" @click="audit('approve')">通过</view>
</view>
</view>
</view>
<view v-else class="empty">{{ err || '加载中…' }}</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import FyFiles from '../../components/fy-files.vue'
import { store } from '../../utils/store.js'
import { oaGet } from '../../utils/api.js'
import { toast } from '../../utils/format.js'
import { collectFiles, fileId } from '../../utils/files.js'
const LABELS = {
reason: '事由', remark: '说明', project: '关联项目', expenseType: '费用类型',
receipts: '票据张数', city: '出差地点', leaveType: '假期类型', days: '时长',
targetName: '对象', lastDay: '最后工作日', proxyPayee: '代付收款人',
offsetLoanNo: '冲账借支', goods: '采购/领用内容', supplier: '意向供应商',
needDate: '期望到货', sealCompany: '用章主体', sealType: '申请印章',
amount: '金额', costType: '费用归属', start: '开始', end: '结束',
hours: '加班时长', date: '日期', place: '地点', punchDate: '补卡日期',
punchKind: '补卡类型', fromDept: '原部门', toDept: '拟调入部门',
newTitle: '拟任岗位', currentStatus: '当前身份', hiredAt: '入职日期',
summary: '工作总结', nextPlan: '转正后计划', qty: '数量'
}
export default {
components: { FyHeader, FyFiles },
data() {
return { no: '', row: null, comment: '', err: '' }
},
computed: {
form() {
const r = this.row || {}
return r.form && typeof r.form === 'object' ? { ...r, ...r.form } : r
},
fields() {
const f = this.form
const skip = new Set(['invoices', 'files', 'attachments', 'invoiceFiles', 'receiptFiles', 'title', 'type', 'typeLabel', 'no', 'status', 'submitter', 'node', 'logs', 'approverUsername', 'id'])
const out = []
Object.keys(f).forEach((key) => {
if (skip.has(key)) return
const v = f[key]
if (v == null || v === '' || typeof v === 'object') return
out.push({ label: LABELS[key] || key, value: String(v) })
})
return out
},
invoices() {
const f = this.form
return Array.isArray(f.invoices) ? f.invoices : []
},
files() {
const all = collectFiles(this.row)
const used = new Set()
this.invoices.forEach((inv) => (inv.files || []).forEach((f) => {
const id = fileId(f)
if (id) used.add(id)
}))
return all.filter((f) => !used.has(fileId(f)))
},
logs() {
const logs = (this.row && this.row.logs) || []
return Array.isArray(logs) ? [...logs].reverse() : []
},
canAct() {
if (!this.row) return false
const st = String(this.row.status || '')
const pending = !st || st === 'pending' || st === '待审批' || this.row.canAudit === true
return store.can('personal', 'approve') && pending
}
},
onLoad(q) {
this.no = decodeURIComponent(q.no || '')
this.load()
},
methods: {
async load() {
try {
this.row = await oaGet('/workflow/requests/' + encodeURIComponent(this.no), store.token)
} catch (e) {
this.err = e.message || '加载失败'
}
},
async audit(action) {
try {
await store.audit(this.no, action, this.comment)
toast(action === 'approve' ? '已通过' : '已驳回', 'success')
setTimeout(() => uni.navigateBack(), 500)
} catch (e) {
toast(e.message || '操作失败')
}
}
}
}
</script>
<style scoped>
.body { padding: 24rpx; padding-bottom: 80rpx; }
.card { padding: 28rpx; margin-bottom: 16rpx; }
.block { padding: 24rpx 28rpx; }
.h { display: block; font-size: 26rpx; font-weight: 700; margin-bottom: 12rpx; }
.t { display: block; font-size: 34rpx; font-weight: 700; }
.s { display: block; font-size: 24rpx; color: #737686; margin-top: 8rpx; }
.amt { display: block; margin-top: 12rpx; color: #004ac6; font-weight: 700; font-size: 32rpx; }
.kv { display: flex; padding: 10rpx 0; border-bottom: 1rpx solid #eaedff; }
.k { width: 180rpx; color: #737686; font-size: 24rpx; }
.v { flex: 1; font-size: 26rpx; }
.inv { padding: 12rpx 0; border-bottom: 1rpx solid #eaedff; }
.tn { display: block; font-weight: 600; font-size: 26rpx; }
.log { padding: 12rpx 0; border-bottom: 1rpx solid #eaedff; }
.who { display: block; font-size: 26rpx; font-weight: 600; }
.field { margin: 24rpx 0; }
.lab { font-size: 24rpx; color: #434655; }
.box { background: #fff; border-radius: 16rpx; padding: 20rpx; min-height: 140rpx; }
.btns { display: flex; gap: 16rpx; }
.btn { flex: 1; height: 88rpx; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; }
.no { background: #ffdad6; color: #ba1a1a; }
.yes { flex: 1; }
.empty { padding: 80rpx; text-align: center; color: #737686; }
</style>
+93
View File
@@ -0,0 +1,93 @@
<template>
<view class="page">
<fy-header title="待办审批" back />
<view v-if="!can" class="deny">当前账号没有审批或申请权限</view>
<view v-else>
<view class="tabs">
<text v-for="t in tabs" :key="t.id" class="tab" :class="{ on: tab === t.id }" @click="switchTab(t.id)">{{ t.title }}</text>
</view>
<view v-if="loading" class="empty">加载中</view>
<view v-else-if="!rows.length" class="empty">暂无记录</view>
<view v-for="r in rows" :key="r.no || r.id" class="card item" @click="open(r)">
<view>
<text class="t">{{ r.title || r.no }}</text>
<text class="s">{{ r.typeLabel || r.type }} · {{ r.submitter || r.assigneeName || '' }}</text>
</view>
<text class="chip">{{ r.status || '待处理' }}</text>
</view>
</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import { store } from '../../utils/store.js'
import { asList } from '../../utils/format.js'
import { oaGet } from '../../utils/api.js'
export default {
components: { FyHeader },
data() {
return {
tab: 'pending',
tabs: [
{ id: 'pending', title: '待处理' },
{ id: 'review', title: '我发起的' },
{ id: 'handled', title: '已处理' },
{ id: 'reviewed', title: '已审阅' }
],
rows: [],
loading: false
}
},
computed: {
can() { return store.can('personal', 'approve') || store.can('personal', 'apply') }
},
onLoad(q) {
if (q.tab) this.tab = q.tab
},
onShow() { if (this.can) this.load() },
methods: {
switchTab(id) {
this.tab = id
this.load()
},
async load() {
this.loading = true
try {
const page = await oaGet(`/workflow/requests?tab=${this.tab}&page=1&size=50`, store.token)
this.rows = asList(page && page.records ? page.records : page)
if (this.tab === 'pending') {
try {
const work = asList(await oaGet('/work-tasks?tab=pending', store.token)).map((t) => ({
...t, typeLabel: '工作协同', title: t.title, source: 'work'
}))
this.rows = [...this.rows, ...work]
} catch (_) {}
}
} catch (_) {
this.rows = []
} finally {
this.loading = false
}
},
open(r) {
if (r.source === 'work' || r.taskId) {
uni.navigateTo({ url: '/pages/coop/detail?id=' + encodeURIComponent(r.id || r.taskId) })
return
}
uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(r.no || r.id || '') })
}
}
}
</script>
<style scoped>
.tabs { display: flex; padding: 16rpx 16rpx 0; }
.tab { flex: 1; text-align: center; font-size: 24rpx; color: #737686; padding: 16rpx 0; }
.tab.on { color: #2563eb; font-weight: 700; border-bottom: 4rpx solid #2563eb; }
.item { margin: 16rpx 24rpx; padding: 24rpx; display: flex; justify-content: space-between; align-items: center; }
.t { display: block; font-size: 28rpx; font-weight: 600; }
.s { font-size: 22rpx; color: #737686; }
.empty, .deny { padding: 80rpx; text-align: center; color: #737686; }
</style>