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
+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>