oa_app仓库初始化
@@ -0,0 +1,5 @@
|
|||||||
|
unpackage/
|
||||||
|
node_modules/
|
||||||
|
.hbuilderx/
|
||||||
|
*.apk
|
||||||
|
*.ipa
|
||||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "1a26b2ef",
|
||||||
|
"configHash": "3a48a73a",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "d8cd8e9b",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script>
|
||||||
|
import { store } from './utils/store.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
onLaunch() {
|
||||||
|
store.boot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
page {
|
||||||
|
background: #faf8ff;
|
||||||
|
color: #131b2e;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #faf8ff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
|
||||||
|
border: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
.muted { color: #737686; }
|
||||||
|
.ink { color: #131b2e; }
|
||||||
|
.brand { color: #2563eb; }
|
||||||
|
.btn-primary {
|
||||||
|
height: 96rpx;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn-primary[disabled] { opacity: 0.6; }
|
||||||
|
.chip {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #eaedff;
|
||||||
|
color: #004ac6;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
min-width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: #ba1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.row-line { height: 1rpx; background: #eaedff; margin-left: 136rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 风影随行通讯 · HBuilder 手机端
|
||||||
|
|
||||||
|
完整交接见 **[交接.md](./交接.md)**。
|
||||||
|
|
||||||
|
工作台入口只认 `/auth/my-perms`,和 Web 同一套,不按人名/岗位写死。
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="files.length || editable" class="wrap">
|
||||||
|
<text v-if="label" class="lab">{{ label }}</text>
|
||||||
|
<view class="list">
|
||||||
|
<view v-for="(f, i) in files" :key="fileKey(f, i)" class="chip" @click="open(f)">
|
||||||
|
<text class="name">{{ displayName(f) }}</text>
|
||||||
|
<text v-if="editable" class="del" @click.stop="$emit('remove', i)">×</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="editable" class="add" @click="add">+ {{ files.length ? '再加一份' : '上传附件' }}</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="!files.length && !editable" class="empty">无附件</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { store } from '../utils/store.js'
|
||||||
|
import { fileId, fileName, pickAndUpload, openFile } from '../utils/files.js'
|
||||||
|
import { toast } from '../utils/format.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FyFiles',
|
||||||
|
props: {
|
||||||
|
files: { type: Array, default: () => [] },
|
||||||
|
label: { type: String, default: '附件' },
|
||||||
|
editable: { type: Boolean, default: false },
|
||||||
|
kind: { type: String, default: 'attach' }
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fileKey(f, i) { return fileId(f) || fileName(f) + i },
|
||||||
|
displayName(f) { return fileName(f) },
|
||||||
|
async add() {
|
||||||
|
try {
|
||||||
|
const rows = await pickAndUpload(store.token, this.kind)
|
||||||
|
if (rows.length) this.$emit('update:files', [...this.files, ...rows])
|
||||||
|
} catch (e) {
|
||||||
|
toast((e && e.message) || '上传失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open(f) {
|
||||||
|
openFile(f, store.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wrap { margin: 12rpx 0; }
|
||||||
|
.lab { font-size: 24rpx; color: #434655; display: block; margin-bottom: 8rpx; }
|
||||||
|
.list { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||||
|
.chip, .add {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 10rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx solid #dbe1ff;
|
||||||
|
color: #2563eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.name { max-width: 360rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.del { color: #ba1a1a; margin-left: 10rpx; font-size: 28rpx; line-height: 1; }
|
||||||
|
.add { color: #434655; border-style: dashed; }
|
||||||
|
.empty { font-size: 22rpx; color: #737686; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<view class="hd" :style="{ paddingTop: status + 'px' }">
|
||||||
|
<view class="bar">
|
||||||
|
<view class="left" @click="onLeft">
|
||||||
|
<text v-if="leftLabel" class="leftlab">{{ leftLabel }}</text>
|
||||||
|
<text v-else-if="back" class="back">‹</text>
|
||||||
|
<text class="title">{{ title }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="right">
|
||||||
|
<slot name="right" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'FyHeader',
|
||||||
|
props: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
back: { type: Boolean, default: false },
|
||||||
|
leftLabel: { type: String, default: '' }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return { status: 20 }
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
try {
|
||||||
|
this.status = uni.getSystemInfoSync().statusBarHeight || 20
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onLeft() {
|
||||||
|
if (this.leftLabel) {
|
||||||
|
this.$emit('left')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.back) {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 1) uni.navigateBack()
|
||||||
|
else uni.switchTab({ url: '/pages/tabs/messages' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hd {
|
||||||
|
background: rgba(250, 248, 255, 0.96);
|
||||||
|
border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 88rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.left { display: flex; align-items: center; min-width: 0; }
|
||||||
|
.back { font-size: 48rpx; line-height: 1; margin-right: 8rpx; color: #131b2e; width: 48rpx; }
|
||||||
|
.leftlab { font-size: 30rpx; color: #131b2e; margin-right: 16rpx; }
|
||||||
|
.title { font-size: 36rpx; font-weight: 600; color: #131b2e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 420rpx; }
|
||||||
|
.right { display: flex; align-items: center; gap: 8rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
||||||
|
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||||
|
</script>
|
||||||
|
<title>风影随行通讯</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import App from './App'
|
||||||
|
|
||||||
|
import { createSSRApp } from 'vue'
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App)
|
||||||
|
return { app }
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "风影随行通讯",
|
||||||
|
"appid": "__UNI__FYSX01",
|
||||||
|
"description": "风影随行通讯 · 移动协同办公",
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"versionCode": "100",
|
||||||
|
"transformPx": false,
|
||||||
|
"vueVersion": "3",
|
||||||
|
"app-plus": {
|
||||||
|
"usingComponents": true,
|
||||||
|
"nvueStyleCompiler": "uni-app",
|
||||||
|
"compilerVersion": 3,
|
||||||
|
"splashscreen": {
|
||||||
|
"alwaysShowBeforeRender": true,
|
||||||
|
"waiting": true,
|
||||||
|
"autoclose": true,
|
||||||
|
"delay": 0
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"Geolocation": {},
|
||||||
|
"Camera": {}
|
||||||
|
},
|
||||||
|
"distribute": {
|
||||||
|
"android": {
|
||||||
|
"permissions": [
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||||
|
],
|
||||||
|
"abiFilters": ["armeabi-v7a", "arm64-v8a"],
|
||||||
|
"minSdkVersion": 21,
|
||||||
|
"targetSdkVersion": 33
|
||||||
|
},
|
||||||
|
"ios": {},
|
||||||
|
"sdkConfigs": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickapp": {},
|
||||||
|
"mp-weixin": {
|
||||||
|
"appid": "",
|
||||||
|
"setting": { "urlCheck": false },
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"h5": {
|
||||||
|
"title": "风影随行通讯",
|
||||||
|
"router": { "mode": "hash" },
|
||||||
|
"devServer": { "https": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{ "path": "pages/login/login", "style": { "navigationStyle": "custom", "navigationBarTitleText": "登录" } },
|
||||||
|
{ "path": "pages/password/password", "style": { "navigationStyle": "custom", "navigationBarTitleText": "修改密码" } },
|
||||||
|
{ "path": "pages/tabs/messages", "style": { "navigationStyle": "custom", "navigationBarTitleText": "消息" } },
|
||||||
|
{ "path": "pages/tabs/contacts", "style": { "navigationStyle": "custom", "navigationBarTitleText": "通讯录" } },
|
||||||
|
{ "path": "pages/tabs/clock", "style": { "navigationStyle": "custom", "navigationBarTitleText": "打卡" } },
|
||||||
|
{ "path": "pages/tabs/workbench", "style": { "navigationStyle": "custom", "navigationBarTitleText": "工作台" } },
|
||||||
|
{ "path": "pages/tabs/me", "style": { "navigationStyle": "custom", "navigationBarTitleText": "我" } },
|
||||||
|
{ "path": "pages/chat/chat", "style": { "navigationStyle": "custom", "navigationBarTitleText": "聊天", "disableScroll": true, "app-plus": { "softinputMode": "adjustNothing", "softinputNavBar": "none", "bounce": "none" } } },
|
||||||
|
{ "path": "pages/chat/work", "style": { "navigationStyle": "custom", "navigationBarTitleText": "工作通知" } },
|
||||||
|
{ "path": "pages/chat/group", "style": { "navigationStyle": "custom", "navigationBarTitleText": "群管理" } },
|
||||||
|
{ "path": "pages/chat/create", "style": { "navigationStyle": "custom", "navigationBarTitleText": "发起群聊" } },
|
||||||
|
{ "path": "pages/chat/forward", "style": { "navigationStyle": "custom", "navigationBarTitleText": "转发" } },
|
||||||
|
{ "path": "pages/chat/setting", "style": { "navigationStyle": "custom", "navigationBarTitleText": "聊天信息" } },
|
||||||
|
{ "path": "pages/chat/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "查找聊天记录" } },
|
||||||
|
{ "path": "pages/chat/call", "style": { "navigationStyle": "custom", "navigationBarTitleText": "通话" } },
|
||||||
|
{ "path": "pages/contact/detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "同事资料" } },
|
||||||
|
{ "path": "pages/apply/list", "style": { "navigationStyle": "custom", "navigationBarTitleText": "发起申请" } },
|
||||||
|
{ "path": "pages/apply/form", "style": { "navigationStyle": "custom", "navigationBarTitleText": "申请" } },
|
||||||
|
{ "path": "pages/todo/list", "style": { "navigationStyle": "custom", "navigationBarTitleText": "待办审批" } },
|
||||||
|
{ "path": "pages/todo/detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "审批详情" } },
|
||||||
|
{ "path": "pages/coop/list", "style": { "navigationStyle": "custom", "navigationBarTitleText": "工作协同" } },
|
||||||
|
{ "path": "pages/coop/create", "style": { "navigationStyle": "custom", "navigationBarTitleText": "新建协同" } },
|
||||||
|
{ "path": "pages/coop/detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "协同详情" } },
|
||||||
|
{ "path": "pages/bills/list", "style": { "navigationStyle": "custom", "navigationBarTitleText": "单据" } },
|
||||||
|
{ "path": "pages/reports/list", "style": { "navigationStyle": "custom", "navigationBarTitleText": "工作汇报" } },
|
||||||
|
{ "path": "pages/me/archive", "style": { "navigationStyle": "custom", "navigationBarTitleText": "我的资料" } },
|
||||||
|
{ "path": "pages/me/payslip", "style": { "navigationStyle": "custom", "navigationBarTitleText": "工资条" } },
|
||||||
|
{ "path": "pages/me/performance", "style": { "navigationStyle": "custom", "navigationBarTitleText": "我的绩效" } },
|
||||||
|
{ "path": "pages/notice/list", "style": { "navigationStyle": "custom", "navigationBarTitleText": "公告" } },
|
||||||
|
{ "path": "pages/erp/hub", "style": { "navigationStyle": "custom", "navigationBarTitleText": "业务" } },
|
||||||
|
{ "path": "pages/search/search", "style": { "navigationStyle": "custom", "navigationBarTitleText": "搜索" } },
|
||||||
|
{ "path": "pages/preview/file", "style": { "navigationStyle": "custom", "navigationBarTitleText": "附件预览" } }
|
||||||
|
],
|
||||||
|
"globalStyle": {
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"navigationBarTitleText": "风影随行通讯",
|
||||||
|
"navigationBarBackgroundColor": "#FAF8FF",
|
||||||
|
"backgroundColor": "#FAF8FF",
|
||||||
|
"backgroundColorTop": "#FAF8FF",
|
||||||
|
"backgroundColorBottom": "#FAF8FF"
|
||||||
|
},
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#434655",
|
||||||
|
"selectedColor": "#2563EB",
|
||||||
|
"backgroundColor": "#FAF8FF",
|
||||||
|
"borderStyle": "white",
|
||||||
|
"list": [
|
||||||
|
{ "pagePath": "pages/tabs/messages", "text": "消息", "iconPath": "static/tab/msg.png", "selectedIconPath": "static/tab/msg-on.png" },
|
||||||
|
{ "pagePath": "pages/tabs/contacts", "text": "通讯录", "iconPath": "static/tab/contacts.png", "selectedIconPath": "static/tab/contacts-on.png" },
|
||||||
|
{ "pagePath": "pages/tabs/clock", "text": "打卡", "iconPath": "static/tab/clock.png", "selectedIconPath": "static/tab/clock-on.png" },
|
||||||
|
{ "pagePath": "pages/tabs/workbench", "text": "工作台", "iconPath": "static/tab/work.png", "selectedIconPath": "static/tab/work-on.png" },
|
||||||
|
{ "pagePath": "pages/tabs/me", "text": "我", "iconPath": "static/tab/me.png", "selectedIconPath": "static/tab/me-on.png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page call">
|
||||||
|
<view class="stage">
|
||||||
|
<web-view v-if="roomSrc" :src="roomSrc" class="wv" />
|
||||||
|
<view v-else class="wait">
|
||||||
|
<view class="av">{{ initial }}</view>
|
||||||
|
<text class="name">{{ peerName }}</text>
|
||||||
|
<text class="st">{{ statusText }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="acts">
|
||||||
|
<view v-if="ringing" class="btn ok" @click="accept">接听</view>
|
||||||
|
<view class="btn no" @click="hang">挂断</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { store } from '../../utils/store.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return { off: null, tick: 0 }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
call() {
|
||||||
|
this.tick
|
||||||
|
return store.call
|
||||||
|
},
|
||||||
|
ringing() {
|
||||||
|
return this.call && this.call.status === 'ringing'
|
||||||
|
},
|
||||||
|
peerName() {
|
||||||
|
const c = this.call
|
||||||
|
if (!c) return '通话'
|
||||||
|
const p = store.person(c.peerId)
|
||||||
|
return (p && p.name) || '同事'
|
||||||
|
},
|
||||||
|
initial() {
|
||||||
|
return this.peerName.slice(0, 1)
|
||||||
|
},
|
||||||
|
statusText() {
|
||||||
|
const s = this.call && this.call.status
|
||||||
|
if (s === 'calling') return '正在呼叫…'
|
||||||
|
if (s === 'ringing') return '邀请你通话'
|
||||||
|
if (s === 'active') return '通话中'
|
||||||
|
return '连接中'
|
||||||
|
},
|
||||||
|
roomSrc() {
|
||||||
|
const c = this.call
|
||||||
|
if (!c || !c.token || !c.url) return ''
|
||||||
|
if (c.status !== 'active' && c.direction !== 'out') return ''
|
||||||
|
return 'https://meet.livekit.io/custom/?liveKitUrl=' + encodeURIComponent(c.url) + '&token=' + encodeURIComponent(c.token)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
if (this.off) this.off()
|
||||||
|
this.off = store.on(() => {
|
||||||
|
this.tick += 1
|
||||||
|
if (!store.call) {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 1) uni.navigateBack()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!store.call) uni.navigateBack()
|
||||||
|
},
|
||||||
|
onHide() {
|
||||||
|
if (this.off) this.off()
|
||||||
|
this.off = null
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
if (this.off) this.off()
|
||||||
|
if (store.call) store.endCall(true)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async accept() {
|
||||||
|
try {
|
||||||
|
uni.showLoading({ title: '接通中…', mask: true })
|
||||||
|
await store.acceptCall()
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: (e && e.message) || '接听失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
try { uni.hideLoading() } catch (_) {}
|
||||||
|
},
|
||||||
|
hang() {
|
||||||
|
store.endCall(true)
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.call { height: 100vh; background: #0f172a; color: #fff; display: flex; flex-direction: column; }
|
||||||
|
.stage { flex: 1; position: relative; }
|
||||||
|
.wv { position: absolute; inset: 0; }
|
||||||
|
.wait { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||||
|
.av { width: 160rpx; height: 160rpx; border-radius: 50%; background: #2563eb; display: flex; align-items: center; justify-content: center; font-size: 64rpx; font-weight: 700; }
|
||||||
|
.name { margin-top: 24rpx; font-size: 36rpx; font-weight: 700; }
|
||||||
|
.st { margin-top: 8rpx; color: rgba(255,255,255,0.65); }
|
||||||
|
.acts { display: flex; justify-content: center; gap: 32rpx; padding: 32rpx 24rpx calc(32rpx + env(safe-area-inset-bottom)); }
|
||||||
|
.btn { min-width: 200rpx; height: 80rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 600; }
|
||||||
|
.ok { background: #006c49; }
|
||||||
|
.no { background: #ba1a1a; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,799 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page chat">
|
||||||
|
<fy-header
|
||||||
|
:title="barTitle"
|
||||||
|
:back="!selecting"
|
||||||
|
:left-label="selecting ? '取消' : ''"
|
||||||
|
@left="exitSelect"
|
||||||
|
>
|
||||||
|
<template #right>
|
||||||
|
<text v-if="!selecting && isGroup" class="ico" @click="goMore">···</text>
|
||||||
|
<text v-else-if="!selecting && peerId" class="ico" @click="goMore">···</text>
|
||||||
|
</template>
|
||||||
|
</fy-header>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
class="msgs"
|
||||||
|
:scroll-y="!recording"
|
||||||
|
:scroll-into-view="tail"
|
||||||
|
scroll-with-animation
|
||||||
|
:refresher-enabled="!recording"
|
||||||
|
:refresher-triggered="refreshing"
|
||||||
|
@refresherrefresh="reload"
|
||||||
|
@click="onMsgsClick"
|
||||||
|
>
|
||||||
|
<view v-if="!list.length" class="empty">暂无消息,发一条试试</view>
|
||||||
|
<view v-for="(m, i) in list" :key="m.id || i">
|
||||||
|
<view v-if="showTime(i)" class="chip-time">{{ timeOf(m) }}</view>
|
||||||
|
<view v-if="m.recalled" class="sys">{{ m.text || '已撤回一条消息' }}</view>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="row"
|
||||||
|
:class="{ mine: m.mine, pick: selecting }"
|
||||||
|
@click="selecting ? togglePick(m) : tapMsg(m)"
|
||||||
|
@longpress="act(m)"
|
||||||
|
>
|
||||||
|
<view v-if="selecting" class="ck" :class="{ on: isPicked(m) }" />
|
||||||
|
<view v-if="!m.mine" class="av" @click.stop="openSender(m)">
|
||||||
|
<image v-if="avatarOf(m)" :src="avatarOf(m)" mode="aspectFill" />
|
||||||
|
<text v-else>{{ initialOf(m) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="col" :class="{ mine: m.mine }">
|
||||||
|
<text v-if="isGroup && !m.mine" class="who">{{ sender(m) }}</text>
|
||||||
|
<view class="ball" :class="ballClass(m)">
|
||||||
|
<view v-if="m.quote" class="quote">{{ quoteLine(m.quote) }}</view>
|
||||||
|
<image v-if="m.kind === 'image' && m.fileUrl" class="pic" :src="m.fileUrl" mode="widthFix" />
|
||||||
|
<view v-else-if="m.kind === 'voice'" class="voice" :style="{ width: voiceW(m) }">
|
||||||
|
<text>{{ m.mine ? '' : '♪' }} {{ voiceLen(m) }}″ {{ m.mine ? '♪' : '' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="m.kind === 'file'" class="file">
|
||||||
|
<text class="fn">{{ m.fileName || m.text || '文件' }}</text>
|
||||||
|
<text class="fs">点击打开</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="isWorkCard(m)" class="cardtxt">
|
||||||
|
<text class="ct">{{ m.title || m.text || '工作卡片' }}</text>
|
||||||
|
<text class="cs">点击查看详情</text>
|
||||||
|
</view>
|
||||||
|
<text v-else>{{ m.text }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="m.mine" class="tm" :class="{ read: m.read || readLabel(m) === '已读' }" @click="showRead(m)">{{ readLabel(m) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="m.mine" class="av self">
|
||||||
|
<image v-if="myAvatar" :src="myAvatar" mode="aspectFill" />
|
||||||
|
<text v-else>{{ myInitial }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view id="tail" />
|
||||||
|
<view id="end" />
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="recording"
|
||||||
|
class="recmask"
|
||||||
|
@touchend="holdEnd"
|
||||||
|
@touchcancel="holdEnd"
|
||||||
|
>
|
||||||
|
<view class="recbox" :class="{ cancel: recWillCancel }">
|
||||||
|
<text class="recsec">{{ recSec }}″</text>
|
||||||
|
<text>{{ recWillCancel ? '松开取消' : '松开发送,上滑取消' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="actionMsg && !selecting" class="mask" @click="closeAct">
|
||||||
|
<view class="sheet" :style="{ paddingBottom: (safeBottom + 12) + 'px' }" @click.stop>
|
||||||
|
<text class="sheet-t">消息操作</text>
|
||||||
|
<text class="sheet-s">{{ actPreview }}</text>
|
||||||
|
<view class="sheet-grid">
|
||||||
|
<view v-if="actionMsg.kind === 'voice'" class="cell" @click="doPlay"><text class="ce">▶</text><text>播放</text></view>
|
||||||
|
<view v-if="actionMsg.kind !== 'voice'" class="cell" @click="doCopy"><text class="ce">文</text><text>复制</text></view>
|
||||||
|
<view class="cell" @click="doForward"><text class="ce">↪</text><text>转发</text></view>
|
||||||
|
<view class="cell" @click="doQuote"><text class="ce">❝</text><text>引用</text></view>
|
||||||
|
<view class="cell" @click="enterSelect"><text class="ce">多</text><text>多选</text></view>
|
||||||
|
<view v-if="canRecall" class="cell" @click="doRecall"><text class="ce">↩</text><text>撤回</text></view>
|
||||||
|
<view class="cell" @click="doDelete"><text class="ce">删</text><text>删除</text></view>
|
||||||
|
</view>
|
||||||
|
<view class="sheet-cancel" @click="closeAct">取消</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="selecting" class="selbar" :style="{ paddingBottom: safeBottom + 'px' }">
|
||||||
|
<view class="selbtn" @click="forwardPicked"><text>转发</text></view>
|
||||||
|
<view class="selbtn" @click="mergePicked"><text>合并转发</text></view>
|
||||||
|
<view class="selbtn danger" @click="deletePicked"><text>删除</text></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="!isWork" class="composer" :style="{ paddingBottom: composerPad }">
|
||||||
|
<view v-if="quoting" class="qbar">
|
||||||
|
<text class="qt">{{ quoteLine(quoting) }}</text>
|
||||||
|
<text class="qx" @click="quoting = null">×</text>
|
||||||
|
</view>
|
||||||
|
<view class="bar">
|
||||||
|
<text class="tool" @click="toggleVoice">{{ voiceMode ? '⌨' : '🎤' }}</text>
|
||||||
|
<view
|
||||||
|
v-if="voiceMode"
|
||||||
|
class="hold"
|
||||||
|
@touchstart="holdStart"
|
||||||
|
@touchend="holdEnd"
|
||||||
|
@touchcancel="holdEnd"
|
||||||
|
>
|
||||||
|
{{ recording ? (recWillCancel ? '松开取消' : '松开发送') : '按住 说话' }}
|
||||||
|
</view>
|
||||||
|
<view v-else class="box">
|
||||||
|
<input
|
||||||
|
v-model="draft"
|
||||||
|
confirm-type="send"
|
||||||
|
hold-keyboard
|
||||||
|
:adjust-position="false"
|
||||||
|
cursor-spacing="24"
|
||||||
|
@confirm="send"
|
||||||
|
@focus="onFocus"
|
||||||
|
:placeholder="isGroup ? '输入消息,@ 可提醒成员…' : ' '"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text v-if="isGroup && !voiceMode" class="tool" @click="mention">@</text>
|
||||||
|
<text class="tool" @click="toggleEmoji">☺</text>
|
||||||
|
<text v-if="draft.trim()" class="go" @click="send">发送</text>
|
||||||
|
<text v-else class="tool plus" @click="toggleMore">{{ more ? '×' : '+' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="emoji && !isWork && !selecting" class="panel emo" :style="{ paddingBottom: safeBottom + 'px' }">
|
||||||
|
<text v-for="e in emos" :key="e" class="em" @click="insertEmoji(e)">{{ e }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="more && !isWork && !selecting" class="panel" :style="{ paddingBottom: safeBottom + 'px' }">
|
||||||
|
<view class="tile" @click="pick('album')"><text class="te">🖼</text><text>相册</text></view>
|
||||||
|
<view class="tile" @click="pick('camera')"><text class="te">📷</text><text>拍摄</text></view>
|
||||||
|
<view class="tile" @click="pick('file')"><text class="te">📎</text><text>文件</text></view>
|
||||||
|
<view v-if="!isGroup" class="tile" @click="startCall(false)"><text class="te">☎</text><text>语音通话</text></view>
|
||||||
|
<view v-if="!isGroup" class="tile" @click="startCall(true)"><text class="te">▷</text><text>视频通话</text></view>
|
||||||
|
<view v-if="isGroup" class="tile" @click="mention"><text class="te">@</text><text>提醒成员</text></view>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="isWork" class="hint">工作通知为办件卡片,不支持快捷回复</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FyHeader from '../../components/fy-header.vue'
|
||||||
|
import { store, previewOf } from '../../utils/store.js'
|
||||||
|
import { fmtTime, toast } from '../../utils/format.js'
|
||||||
|
import { uploadLocal, fileTicket, openFile } from '../../utils/files.js'
|
||||||
|
import { mobileUrl } from '../../utils/nav.js'
|
||||||
|
|
||||||
|
const EMOS = ['😀','😁','😂','😅','😊','😍','😘','😜','🤗','🤔','🙄','😴','😭','😡','👍','👎','👌','🙏','👏','💪','🔥','⭐','🎉','❤️','💕','✅','❌','💯','🤝','👀']
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { FyHeader },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
sid: '',
|
||||||
|
draft: '',
|
||||||
|
off: null,
|
||||||
|
tail: '',
|
||||||
|
list: [],
|
||||||
|
title: '聊天',
|
||||||
|
isGroup: false,
|
||||||
|
isWork: false,
|
||||||
|
peerId: '',
|
||||||
|
myAvatar: '',
|
||||||
|
myInitial: '我',
|
||||||
|
memberCount: 0,
|
||||||
|
kb: 0,
|
||||||
|
more: false,
|
||||||
|
emoji: false,
|
||||||
|
voiceMode: false,
|
||||||
|
refreshing: false,
|
||||||
|
safeBottom: 0,
|
||||||
|
recording: false,
|
||||||
|
recWillCancel: false,
|
||||||
|
recSec: 0,
|
||||||
|
actionMsg: null,
|
||||||
|
quoting: null,
|
||||||
|
selecting: false,
|
||||||
|
picked: [],
|
||||||
|
emos: EMOS,
|
||||||
|
_recAt: 0,
|
||||||
|
_recY: 0,
|
||||||
|
_recTimer: 0,
|
||||||
|
_rec: null,
|
||||||
|
_recSkip: false,
|
||||||
|
_playing: null,
|
||||||
|
_kb: null,
|
||||||
|
_kbBound: false,
|
||||||
|
_lastId: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
barTitle() {
|
||||||
|
if (this.selecting) return '已选择 ' + this.picked.length + ' 条消息'
|
||||||
|
if (this.isWork) return '工作通知'
|
||||||
|
if (this.isGroup) return this.title + (this.memberCount ? '(' + this.memberCount + ')' : '')
|
||||||
|
return this.title
|
||||||
|
},
|
||||||
|
composerPad() {
|
||||||
|
if (this.kb > 0) return this.kb + 'px'
|
||||||
|
return (10 + this.safeBottom) + 'px'
|
||||||
|
},
|
||||||
|
canRecall() {
|
||||||
|
const m = this.actionMsg
|
||||||
|
return !!(m && m.mine && !m.recalled && Date.now() - Number(m.time) < 120000)
|
||||||
|
},
|
||||||
|
actPreview() {
|
||||||
|
return previewOf(this.actionMsg)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(q) {
|
||||||
|
this.sid = decodeURIComponent(q.sid || '')
|
||||||
|
try {
|
||||||
|
this.safeBottom = Number(uni.getSystemInfoSync().safeAreaInsets && uni.getSystemInfoSync().safeAreaInsets.bottom) || 0
|
||||||
|
} catch (_) {}
|
||||||
|
this._rec = uni.getRecorderManager()
|
||||||
|
this._rec.onStop((res) => this.afterRecord(res))
|
||||||
|
this._rec.onError(() => {
|
||||||
|
this.recording = false
|
||||||
|
toast('录音失败,请检查麦克风权限')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.bindKb()
|
||||||
|
if (this.off) this.off()
|
||||||
|
store.refreshPeoplePhotos()
|
||||||
|
this.pull()
|
||||||
|
this.off = store.on(() => this.pull())
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
if (c) store.openChat(c)
|
||||||
|
},
|
||||||
|
onHide() {
|
||||||
|
this.unbind()
|
||||||
|
this.recCancel()
|
||||||
|
this.actionMsg = null
|
||||||
|
if (store.activeSid === this.sid) store.setActiveChat('')
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
this.unbind()
|
||||||
|
this.unbindKb()
|
||||||
|
this.recCancel()
|
||||||
|
this.unbindRecDoc()
|
||||||
|
if (store.activeSid === this.sid) store.setActiveChat('')
|
||||||
|
if (this._playing) {
|
||||||
|
try { this._playing.stop() } catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
unbind() {
|
||||||
|
if (this.off) this.off()
|
||||||
|
this.off = null
|
||||||
|
},
|
||||||
|
bindKb() {
|
||||||
|
if (this._kbBound) return
|
||||||
|
this._kbBound = true
|
||||||
|
this._kb = (res) => {
|
||||||
|
this.kb = Number(res && res.height) || 0
|
||||||
|
if (this.kb > 0) {
|
||||||
|
this.more = false
|
||||||
|
this.emoji = false
|
||||||
|
}
|
||||||
|
this.scrollEnd()
|
||||||
|
}
|
||||||
|
uni.onKeyboardHeightChange(this._kb)
|
||||||
|
},
|
||||||
|
unbindKb() {
|
||||||
|
if (this._kb) {
|
||||||
|
try { uni.offKeyboardHeightChange(this._kb) } catch (_) {}
|
||||||
|
}
|
||||||
|
this._kb = null
|
||||||
|
this._kbBound = false
|
||||||
|
},
|
||||||
|
pull() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
this.title = (c && c.name) || '聊天'
|
||||||
|
this.isGroup = !!(c && c.type === 'group')
|
||||||
|
this.isWork = !!(c && c.type === 'work')
|
||||||
|
this.peerId = (c && c.peerId) || ''
|
||||||
|
this.memberCount = c && c.memberIds ? c.memberIds.length : 0
|
||||||
|
this.myAvatar = (store.me && store.me.avatar) || ''
|
||||||
|
this.myInitial = ((store.me && store.me.name) || '我').slice(0, 1)
|
||||||
|
const next = ((c && c.messages) || []).map((m) => ({ ...m }))
|
||||||
|
const lastId = next.length ? next[next.length - 1].id : ''
|
||||||
|
this.list = next
|
||||||
|
if (lastId !== this._lastId) {
|
||||||
|
this._lastId = lastId
|
||||||
|
this.scrollEnd()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollEnd() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.tail = this.tail === 'end' ? 'tail' : 'end'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async reload() {
|
||||||
|
this.refreshing = true
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
try { if (c) await store.openChat(c) } finally { this.refreshing = false }
|
||||||
|
},
|
||||||
|
showTime(i) {
|
||||||
|
if (i === 0) return true
|
||||||
|
return Math.abs(Number(this.list[i].time) - Number(this.list[i - 1].time)) > 5 * 60 * 1000
|
||||||
|
},
|
||||||
|
timeOf(m) { return fmtTime(m.time) },
|
||||||
|
voiceLen(m) { return Math.max(1, Number(m.duration) || 1) },
|
||||||
|
voiceW(m) { return Math.min(360, 120 + this.voiceLen(m) * 12) + 'rpx' },
|
||||||
|
readLabel(m) {
|
||||||
|
if (!m || !m.mine || m.recalled) return ''
|
||||||
|
if (this.isGroup && m.readReceipt) {
|
||||||
|
const r = (m.readReceipt.read || []).length
|
||||||
|
const u = (m.readReceipt.unread || []).length
|
||||||
|
return '已读 ' + r + '/' + (r + u)
|
||||||
|
}
|
||||||
|
return m.read ? '已读' : '未读'
|
||||||
|
},
|
||||||
|
showRead(m) {
|
||||||
|
if (!this.isGroup || !m || !m.readReceipt) return
|
||||||
|
const r = (m.readReceipt.read || []).map((p) => p.name || p).join('、') || '暂无'
|
||||||
|
const u = (m.readReceipt.unread || []).map((p) => p.name || p).join('、') || '暂无'
|
||||||
|
uni.showModal({
|
||||||
|
title: '已读详情',
|
||||||
|
content: '已读:' + r + '\n未读:' + u,
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sender(m) {
|
||||||
|
if (m.mine) return (store.me && store.me.name) || '我'
|
||||||
|
const p = store.person(m.fromId)
|
||||||
|
return (p && p.name) || this.title || '同事'
|
||||||
|
},
|
||||||
|
avatarOf(m) {
|
||||||
|
if (m.mine) return this.myAvatar
|
||||||
|
const p = store.person(m.fromId)
|
||||||
|
if (p && p.avatar) return p.avatar
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
return (c && c.avatar) || ''
|
||||||
|
},
|
||||||
|
initialOf(m) { return (this.sender(m) || '?').slice(0, 1) },
|
||||||
|
isWorkCard(m) { return m.kind === 'card' || (this.isWork && !m.mine) },
|
||||||
|
quoteLine(q) {
|
||||||
|
if (!q) return ''
|
||||||
|
const name = q.name || this.sender(q) || ''
|
||||||
|
return (name ? name + ':' : '') + (q.text || previewOf(q))
|
||||||
|
},
|
||||||
|
ballClass(m) {
|
||||||
|
return {
|
||||||
|
self: m.mine && !m.recalled,
|
||||||
|
work: this.isWorkCard(m),
|
||||||
|
voice: m.kind === 'voice'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMsgsClick() {
|
||||||
|
if (this.more || this.emoji) {
|
||||||
|
this.more = false
|
||||||
|
this.emoji = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFocus() {
|
||||||
|
this.more = false
|
||||||
|
this.emoji = false
|
||||||
|
this.voiceMode = false
|
||||||
|
},
|
||||||
|
hideKb() {
|
||||||
|
try { uni.hideKeyboard() } catch (_) {}
|
||||||
|
this.kb = 0
|
||||||
|
},
|
||||||
|
toggleMore() {
|
||||||
|
this.emoji = false
|
||||||
|
this.more = !this.more
|
||||||
|
if (this.more) this.hideKb()
|
||||||
|
},
|
||||||
|
toggleEmoji() {
|
||||||
|
this.more = false
|
||||||
|
this.emoji = !this.emoji
|
||||||
|
if (this.emoji) this.hideKb()
|
||||||
|
},
|
||||||
|
toggleVoice() {
|
||||||
|
this.voiceMode = !this.voiceMode
|
||||||
|
this.more = false
|
||||||
|
this.emoji = false
|
||||||
|
this.hideKb()
|
||||||
|
},
|
||||||
|
insertEmoji(e) {
|
||||||
|
this.draft = (this.draft || '') + (e || '🙂')
|
||||||
|
},
|
||||||
|
send() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
const text = String(this.draft || '').trim()
|
||||||
|
if (!c || this.isWork || !text) return
|
||||||
|
this.draft = ''
|
||||||
|
store.sendText(c, text, this.quoting ? { quote: { ...this.quoting, name: this.quoting.name || this.sender(this.quoting) } } : null)
|
||||||
|
this.quoting = null
|
||||||
|
},
|
||||||
|
holdStart(e) {
|
||||||
|
if (this.isWork || this.recording) return
|
||||||
|
const t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || {}
|
||||||
|
this._recY = Number(t.clientY || t.pageY || 0)
|
||||||
|
this.recWillCancel = false
|
||||||
|
this._recSkip = false
|
||||||
|
this.recording = true
|
||||||
|
this.recSec = 0
|
||||||
|
this._recAt = Date.now()
|
||||||
|
if (this._recTimer) clearInterval(this._recTimer)
|
||||||
|
this._recTimer = setInterval(() => {
|
||||||
|
this.recSec = Math.floor((Date.now() - this._recAt) / 1000)
|
||||||
|
}, 300)
|
||||||
|
this.bindRecDoc()
|
||||||
|
try {
|
||||||
|
this._rec.start({ format: 'mp3', duration: 60000, sampleRate: 16000 })
|
||||||
|
} catch (_) {
|
||||||
|
this.unbindRecDoc()
|
||||||
|
this.recording = false
|
||||||
|
toast('无法开始录音')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
holdMove(e) {
|
||||||
|
if (!this.recording) return
|
||||||
|
const t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || {}
|
||||||
|
const y = Number(t.clientY || t.pageY || 0)
|
||||||
|
this.recWillCancel = !!(this._recY && y && this._recY - y > 70)
|
||||||
|
},
|
||||||
|
holdEnd() {
|
||||||
|
if (!this.recording) return
|
||||||
|
this.unbindRecDoc()
|
||||||
|
if (this.recWillCancel) {
|
||||||
|
this.recCancel()
|
||||||
|
toast('已取消')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.recording = false
|
||||||
|
this.recWillCancel = false
|
||||||
|
if (this._recTimer) clearInterval(this._recTimer)
|
||||||
|
try { this._rec.stop() } catch (_) {}
|
||||||
|
},
|
||||||
|
bindRecDoc() {
|
||||||
|
this.unbindRecDoc()
|
||||||
|
try {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
this._onRecMove = (ev) => this.holdMove(ev)
|
||||||
|
this._onRecEnd = () => this.holdEnd()
|
||||||
|
document.addEventListener('touchmove', this._onRecMove, { passive: true, capture: true })
|
||||||
|
document.addEventListener('touchend', this._onRecEnd, { passive: true, capture: true })
|
||||||
|
document.addEventListener('touchcancel', this._onRecEnd, { passive: true, capture: true })
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
unbindRecDoc() {
|
||||||
|
try {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (this._onRecMove) document.removeEventListener('touchmove', this._onRecMove, true)
|
||||||
|
if (this._onRecEnd) {
|
||||||
|
document.removeEventListener('touchend', this._onRecEnd, true)
|
||||||
|
document.removeEventListener('touchcancel', this._onRecEnd, true)
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
this._onRecMove = null
|
||||||
|
this._onRecEnd = null
|
||||||
|
},
|
||||||
|
recCancel() {
|
||||||
|
this.unbindRecDoc()
|
||||||
|
this._recSkip = true
|
||||||
|
this.recording = false
|
||||||
|
this.recWillCancel = false
|
||||||
|
if (this._recTimer) clearInterval(this._recTimer)
|
||||||
|
try { if (this._rec) this._rec.stop() } catch (_) {}
|
||||||
|
},
|
||||||
|
async afterRecord(res) {
|
||||||
|
if (this._recSkip) {
|
||||||
|
this._recSkip = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const path = res && (res.tempFilePath || res.tempFile)
|
||||||
|
const ms = Date.now() - this._recAt
|
||||||
|
if (!path || ms < 600) {
|
||||||
|
toast('说话时间太短')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const conv = store.findConv(this.sid)
|
||||||
|
if (!conv) return
|
||||||
|
try {
|
||||||
|
uni.showLoading({ title: '发送语音…', mask: true })
|
||||||
|
const f = await uploadLocal(path, store.token, { kind: 'im', name: 'voice.mp3' })
|
||||||
|
let url = ''
|
||||||
|
try { url = (await fileTicket(f.id, store.token)).url } catch (_) {}
|
||||||
|
store.sendVoice(conv, { ...f, url, duration: Math.round(ms / 1000) })
|
||||||
|
} catch (e) {
|
||||||
|
toast((e && e.message) || '语音发送失败')
|
||||||
|
}
|
||||||
|
try { uni.hideLoading() } catch (_) {}
|
||||||
|
},
|
||||||
|
playVoice(m) {
|
||||||
|
if (!m.fileUrl) {
|
||||||
|
toast('语音无法播放')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this._playing) {
|
||||||
|
try { this._playing.stop() } catch (_) {}
|
||||||
|
this._playing = null
|
||||||
|
}
|
||||||
|
const audio = uni.createInnerAudioContext()
|
||||||
|
audio.src = m.fileUrl
|
||||||
|
audio.autoplay = true
|
||||||
|
audio.onEnded(() => { try { audio.destroy() } catch (_) {} })
|
||||||
|
this._playing = audio
|
||||||
|
},
|
||||||
|
pick(kind) {
|
||||||
|
this.more = false
|
||||||
|
const conv = store.findConv(this.sid)
|
||||||
|
if (!conv) return
|
||||||
|
const sendPaths = async (paths, names) => {
|
||||||
|
if (!paths.length) return
|
||||||
|
uni.showLoading({ title: '发送中…', mask: true })
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < paths.length; i++) {
|
||||||
|
const f = await uploadLocal(paths[i], store.token, { kind: 'im', name: names[i] })
|
||||||
|
let url = ''
|
||||||
|
try { url = (await fileTicket(f.id, store.token)).url } catch (_) {}
|
||||||
|
store.sendMedia(conv, { ...f, url })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast((e && e.message) || '发送失败')
|
||||||
|
}
|
||||||
|
try { uni.hideLoading() } catch (_) {}
|
||||||
|
}
|
||||||
|
if (kind === 'file') {
|
||||||
|
const chooser = typeof uni.chooseFile === 'function' ? uni.chooseFile : null
|
||||||
|
if (!chooser) return this.pick('album')
|
||||||
|
chooser({
|
||||||
|
count: 6,
|
||||||
|
type: 'all',
|
||||||
|
success(res) {
|
||||||
|
const files = res.tempFiles || []
|
||||||
|
sendPaths(files.map((f) => f.path || f.tempFilePath).filter(Boolean), files.map((f) => f.name || '文件'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.chooseImage({
|
||||||
|
count: kind === 'camera' ? 1 : 9,
|
||||||
|
sizeType: ['compressed', 'original'],
|
||||||
|
sourceType: kind === 'camera' ? ['camera'] : ['album'],
|
||||||
|
success(res) {
|
||||||
|
const paths = res.tempFilePaths || []
|
||||||
|
const files = res.tempFiles || []
|
||||||
|
sendPaths(paths, files.map((f, i) => f.name || ('图片' + (i + 1))))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
act(m) {
|
||||||
|
if (!m || m.recalled || this.selecting) return
|
||||||
|
this.more = false
|
||||||
|
this.emoji = false
|
||||||
|
this.hideKb()
|
||||||
|
this.actionMsg = m
|
||||||
|
},
|
||||||
|
closeAct() { this.actionMsg = null },
|
||||||
|
liveMsg() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
const m = this.actionMsg
|
||||||
|
if (!c || !m) return { c: null, live: null }
|
||||||
|
const live = (c.messages || []).find((x) => x.id === m.id) || m
|
||||||
|
return { c, live }
|
||||||
|
},
|
||||||
|
doCopy() {
|
||||||
|
const m = this.actionMsg
|
||||||
|
if (!m) return
|
||||||
|
uni.setClipboardData({ data: m.text || m.fileUrl || m.fileName || '' })
|
||||||
|
this.closeAct()
|
||||||
|
},
|
||||||
|
doPlay() {
|
||||||
|
if (this.actionMsg) this.playVoice(this.actionMsg)
|
||||||
|
this.closeAct()
|
||||||
|
},
|
||||||
|
doForward() {
|
||||||
|
const m = this.actionMsg
|
||||||
|
if (!m) return
|
||||||
|
store.pendingForward = { fromSid: this.sid, msgs: [{ ...m }], merge: false }
|
||||||
|
this.closeAct()
|
||||||
|
uni.navigateTo({ url: '/pages/chat/forward' })
|
||||||
|
},
|
||||||
|
doQuote() {
|
||||||
|
const m = this.actionMsg
|
||||||
|
if (!m) return
|
||||||
|
this.quoting = { ...m, name: this.sender(m), text: m.kind === 'text' ? (m.text || '') : previewOf(m) }
|
||||||
|
this.voiceMode = false
|
||||||
|
this.closeAct()
|
||||||
|
},
|
||||||
|
doRecall() {
|
||||||
|
const { c, live } = this.liveMsg()
|
||||||
|
if (c && live) store.recall(c, live)
|
||||||
|
this.closeAct()
|
||||||
|
},
|
||||||
|
doDelete() {
|
||||||
|
const { c, live } = this.liveMsg()
|
||||||
|
if (c && live) store.removeMessage(c, live)
|
||||||
|
this.closeAct()
|
||||||
|
},
|
||||||
|
enterSelect() {
|
||||||
|
const m = this.actionMsg
|
||||||
|
this.selecting = true
|
||||||
|
this.picked = m && m.id ? [m.id] : []
|
||||||
|
this.closeAct()
|
||||||
|
},
|
||||||
|
exitSelect() {
|
||||||
|
this.selecting = false
|
||||||
|
this.picked = []
|
||||||
|
},
|
||||||
|
isPicked(m) { return this.picked.indexOf(m.id) >= 0 },
|
||||||
|
togglePick(m) {
|
||||||
|
if (!m || m.recalled) return
|
||||||
|
const i = this.picked.indexOf(m.id)
|
||||||
|
if (i >= 0) this.picked.splice(i, 1)
|
||||||
|
else this.picked.push(m.id)
|
||||||
|
},
|
||||||
|
pickedMsgs() {
|
||||||
|
const set = new Set(this.picked)
|
||||||
|
return this.list.filter((m) => set.has(m.id) && !m.recalled)
|
||||||
|
},
|
||||||
|
forwardPicked() {
|
||||||
|
const msgs = this.pickedMsgs()
|
||||||
|
if (!msgs.length) return toast('请先选择消息')
|
||||||
|
store.pendingForward = { fromSid: this.sid, msgs: msgs.map((m) => ({ ...m })), merge: false }
|
||||||
|
this.exitSelect()
|
||||||
|
uni.navigateTo({ url: '/pages/chat/forward' })
|
||||||
|
},
|
||||||
|
mergePicked() {
|
||||||
|
const msgs = this.pickedMsgs()
|
||||||
|
if (!msgs.length) return toast('请先选择消息')
|
||||||
|
store.pendingForward = { fromSid: this.sid, msgs: msgs.map((m) => ({ ...m })), merge: true }
|
||||||
|
this.exitSelect()
|
||||||
|
uni.navigateTo({ url: '/pages/chat/forward' })
|
||||||
|
},
|
||||||
|
deletePicked() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
const msgs = this.pickedMsgs()
|
||||||
|
if (!c || !msgs.length) return
|
||||||
|
msgs.forEach((m) => store.removeMessage(c, m))
|
||||||
|
this.exitSelect()
|
||||||
|
},
|
||||||
|
tapMsg(m) {
|
||||||
|
if (m.recalled) return
|
||||||
|
if (m.kind === 'voice') return this.playVoice(m)
|
||||||
|
if (m.kind === 'image' && m.fileUrl) {
|
||||||
|
uni.previewImage({ urls: [m.fileUrl], current: m.fileUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (m.kind === 'file') {
|
||||||
|
openFile({ id: m.fileId || '', name: m.fileName || m.text, contentType: '' }, store.token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.isWorkCard(m)) {
|
||||||
|
if (m.href) {
|
||||||
|
const url = mobileUrl(m.href, { no: m.no, id: m.taskId })
|
||||||
|
if (url) uni.navigateTo({ url })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (m.taskId) uni.navigateTo({ url: '/pages/coop/detail?id=' + encodeURIComponent(m.taskId) })
|
||||||
|
else if (m.no) uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(m.no) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openSender(m) {
|
||||||
|
if (m.fromId) uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(m.fromId) })
|
||||||
|
},
|
||||||
|
goMore() {
|
||||||
|
if (this.isGroup) uni.navigateTo({ url: '/pages/chat/group?sid=' + encodeURIComponent(this.sid) })
|
||||||
|
else uni.navigateTo({ url: '/pages/chat/setting?sid=' + encodeURIComponent(this.sid) })
|
||||||
|
},
|
||||||
|
mention() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
const ids = ((c && c.memberIds) || []).filter((id) => !store.me || id !== store.me.id)
|
||||||
|
const names = ids.map((id) => {
|
||||||
|
const p = store.person(id)
|
||||||
|
return (p && p.name) || id
|
||||||
|
}).filter(Boolean).slice(0, 6)
|
||||||
|
if (!names.length) {
|
||||||
|
this.draft = (this.draft || '') + '@'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: names,
|
||||||
|
success: (res) => {
|
||||||
|
const name = names[res.tapIndex]
|
||||||
|
if (name) this.draft = (this.draft || '') + '@' + name + ' '
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async startCall(video) {
|
||||||
|
this.more = false
|
||||||
|
if (!this.peerId) return
|
||||||
|
try {
|
||||||
|
await store.startCall(this.peerId, { video: !!video })
|
||||||
|
} catch (e) {
|
||||||
|
toast((e && e.message) || '无法发起通话')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat { display: flex; flex-direction: column; height: 100vh; overflow: hidden; background: #ededed; }
|
||||||
|
.msgs { flex: 1; height: 0; padding: 12rpx 20rpx 8rpx; box-sizing: border-box; }
|
||||||
|
.chip-time { text-align: center; margin: 16rpx auto; background: rgba(0,0,0,.08); color: #fff; font-size: 20rpx; padding: 4rpx 16rpx; border-radius: 8rpx; width: fit-content; }
|
||||||
|
.sys { text-align: center; color: #888; font-size: 22rpx; padding: 12rpx 0 20rpx; }
|
||||||
|
.empty { text-align: center; color: #888; padding: 80rpx 0; font-size: 26rpx; }
|
||||||
|
.row { margin-bottom: 22rpx; display: flex; align-items: flex-start; gap: 12rpx; }
|
||||||
|
.row.mine { justify-content: flex-end; }
|
||||||
|
.row.pick { padding-left: 0; }
|
||||||
|
.ck { width: 36rpx; height: 36rpx; border: 2rpx solid #bbb; border-radius: 50%; margin-top: 20rpx; flex-shrink: 0; }
|
||||||
|
.ck.on { background: #07c160; border-color: #07c160; }
|
||||||
|
.col { max-width: 70%; display: flex; flex-direction: column; }
|
||||||
|
.col.mine { align-items: flex-end; }
|
||||||
|
.av { width: 80rpx; height: 80rpx; border-radius: 10rpx; background: #dae2fd; color: #004ac6; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 700; }
|
||||||
|
.av.self { background: #2563eb; color: #fff; }
|
||||||
|
.av image { width: 80rpx; height: 80rpx; }
|
||||||
|
.who { font-size: 20rpx; color: #888; margin-bottom: 4rpx; }
|
||||||
|
.ball { background: #fff; padding: 16rpx 20rpx; border-radius: 8rpx; font-size: 30rpx; word-break: break-all; line-height: 1.45; }
|
||||||
|
.ball.self { background: #95ec69; color: #111; }
|
||||||
|
.ball.work { background: #fff; }
|
||||||
|
.ball.voice { min-width: 140rpx; }
|
||||||
|
.quote { background: rgba(0,0,0,.06); color: #666; font-size: 22rpx; padding: 8rpx 12rpx; border-radius: 6rpx; margin-bottom: 10rpx; }
|
||||||
|
.pic { max-width: 420rpx; border-radius: 8rpx; display: block; }
|
||||||
|
.file, .cardtxt { display: flex; flex-direction: column; }
|
||||||
|
.fn, .ct { font-weight: 600; }
|
||||||
|
.fs, .cs { font-size: 22rpx; margin-top: 6rpx; color: #2563eb; }
|
||||||
|
.tm { font-size: 20rpx; color: #b2b2b2; margin-top: 6rpx; }
|
||||||
|
.tm.read { color: #07c160; }
|
||||||
|
.recmask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.01);
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.recbox { width: 360rpx; height: 280rpx; background: rgba(0,0,0,.72); color: #fff; border-radius: 20rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 26rpx; }
|
||||||
|
.recbox.cancel { background: rgba(186,26,26,.85); }
|
||||||
|
.recsec { font-size: 48rpx; font-weight: 700; margin-bottom: 12rpx; }
|
||||||
|
.composer { background: #f7f7f7; border-top: 1rpx solid #e5e5e5; }
|
||||||
|
.qbar { display: flex; align-items: center; padding: 10rpx 20rpx 0; }
|
||||||
|
.qt { flex: 1; font-size: 22rpx; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.qx { padding: 0 12rpx; color: #888; }
|
||||||
|
.bar { display: flex; align-items: flex-end; padding: 12rpx 12rpx 0; gap: 10rpx; }
|
||||||
|
.tool { width: 64rpx; height: 64rpx; text-align: center; line-height: 64rpx; font-size: 36rpx; color: #111; }
|
||||||
|
.plus { font-size: 44rpx; font-weight: 300; }
|
||||||
|
.box { flex: 1; min-height: 72rpx; background: #fff; border-radius: 8rpx; display: flex; align-items: center; padding: 0 16rpx; }
|
||||||
|
.box input { flex: 1; height: 72rpx; }
|
||||||
|
.hold {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 72rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.go { height: 64rpx; padding: 0 24rpx; background: #07c160; color: #fff; border-radius: 8rpx; font-size: 26rpx; font-weight: 600; display: flex; align-items: center; margin-bottom: 4rpx; }
|
||||||
|
.panel { display: flex; flex-wrap: wrap; background: #f7f7f7; padding: 20rpx 24rpx 24rpx; gap: 24rpx; }
|
||||||
|
.panel.emo { gap: 8rpx; }
|
||||||
|
.tile { width: 140rpx; height: 140rpx; background: #fff; border-radius: 16rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 22rpx; color: #333; }
|
||||||
|
.te { font-size: 48rpx; margin-bottom: 8rpx; }
|
||||||
|
.em { width: 12.5%; text-align: center; font-size: 44rpx; line-height: 88rpx; }
|
||||||
|
.hint { text-align: center; font-size: 22rpx; color: #888; padding: 20rpx; }
|
||||||
|
.ico { padding: 8rpx 16rpx; font-size: 36rpx; }
|
||||||
|
.mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,.45); z-index: 40; display: flex; align-items: flex-end; }
|
||||||
|
.sheet { width: 100%; background: #fff; border-radius: 24rpx 24rpx 0 0; padding: 28rpx 28rpx 12rpx; }
|
||||||
|
.sheet-t { display: block; text-align: center; font-size: 30rpx; font-weight: 700; }
|
||||||
|
.sheet-s { display: block; text-align: center; font-size: 22rpx; color: #888; margin: 8rpx 0 24rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sheet-grid { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
||||||
|
.cell { width: calc(25% - 12rpx); height: 140rpx; background: #f7f7f7; border-radius: 16rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 22rpx; color: #333; }
|
||||||
|
.ce { font-size: 36rpx; margin-bottom: 8rpx; color: #111; font-weight: 700; }
|
||||||
|
.sheet-cancel { margin: 20rpx 0 8rpx; height: 88rpx; border-radius: 16rpx; background: #f7f7f7; display: flex; align-items: center; justify-content: center; font-size: 28rpx; }
|
||||||
|
.selbar { display: flex; background: #fff; border-top: 1rpx solid #eee; }
|
||||||
|
.selbtn { flex: 1; height: 96rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; }
|
||||||
|
.selbtn.danger { color: #fa5151; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<fy-header title="发起群聊" back />
|
||||||
|
<view class="card field">
|
||||||
|
<input v-model="name" placeholder="群名称(必填)" />
|
||||||
|
</view>
|
||||||
|
<view class="hint">已选 {{ picked.length }} 人</view>
|
||||||
|
<view class="card">
|
||||||
|
<view v-for="p in people" :key="p.id" class="item" @click="toggle(p.id)">
|
||||||
|
<view class="box" :class="{ on: picked.includes(p.id) }" />
|
||||||
|
<view class="av">{{ p.name.slice(0, 1) }}</view>
|
||||||
|
<view class="meta">
|
||||||
|
<text class="n">{{ p.name }}</text>
|
||||||
|
<text class="s">{{ p.title }} · {{ p.department }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="pad">
|
||||||
|
<view class="btn-primary" :class="{ off: !can }" @click="ok">创建并进入</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FyHeader from '../../components/fy-header.vue'
|
||||||
|
import { store } from '../../utils/store.js'
|
||||||
|
import { toast } from '../../utils/format.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { FyHeader },
|
||||||
|
data() {
|
||||||
|
return { name: '', picked: [] }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
people() {
|
||||||
|
return Object.values(store.people).filter((p) => !store.me || p.id !== store.me.id)
|
||||||
|
},
|
||||||
|
can() {
|
||||||
|
return String(this.name || '').trim() && this.picked.length >= 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggle(id) {
|
||||||
|
const i = this.picked.indexOf(id)
|
||||||
|
if (i >= 0) this.picked.splice(i, 1)
|
||||||
|
else this.picked.push(id)
|
||||||
|
},
|
||||||
|
async ok() {
|
||||||
|
if (!this.can) {
|
||||||
|
toast('请填写群名并至少选一位同事')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
uni.showLoading({ title: '创建中…', mask: true })
|
||||||
|
const conv = await store.createGroup(this.name.trim(), this.picked)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.redirectTo({ url: '/pages/chat/chat?sid=' + encodeURIComponent(conv.id) })
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading()
|
||||||
|
toast((e && e.message) || '创建失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field { margin: 24rpx; padding: 8rpx 24rpx; }
|
||||||
|
.field input { height: 80rpx; font-size: 30rpx; }
|
||||||
|
.hint { margin: 0 32rpx 12rpx; font-size: 22rpx; color: #737686; }
|
||||||
|
.card { margin: 0 24rpx 24rpx; overflow: hidden; }
|
||||||
|
.item { display: flex; align-items: center; padding: 20rpx 24rpx; }
|
||||||
|
.box { width: 36rpx; height: 36rpx; border: 2rpx solid #c3c6d7; border-radius: 8rpx; margin-right: 16rpx; }
|
||||||
|
.box.on { background: #2563eb; border-color: #2563eb; }
|
||||||
|
.av { width: 72rpx; height: 72rpx; border-radius: 50%; background: #e2e7ff; color: #004ac6; display: flex; align-items: center; justify-content: center; font-weight: 600; }
|
||||||
|
.meta { margin-left: 16rpx; }
|
||||||
|
.n { display: block; font-size: 28rpx; font-weight: 600; }
|
||||||
|
.s { font-size: 22rpx; color: #737686; }
|
||||||
|
.pad { padding: 0 24rpx 40rpx; }
|
||||||
|
.off { opacity: 0.45; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<fy-header title="选择聊天" back />
|
||||||
|
<view class="search">
|
||||||
|
<input v-model="q" placeholder="搜索" />
|
||||||
|
</view>
|
||||||
|
<view class="snip">
|
||||||
|
<text>{{ hint }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="convs.length" class="sec">
|
||||||
|
<text>最近聊天</text>
|
||||||
|
</view>
|
||||||
|
<view class="list">
|
||||||
|
<view v-if="!convs.length && !people.length" class="empty">暂无可转发对象</view>
|
||||||
|
<view v-for="c in convs" :key="'c' + c.id" class="item" @click="pickConv(c)">
|
||||||
|
<image v-if="c.avatar" class="av img" :src="c.avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="av">{{ (c.name || '?').slice(0, 1) }}</view>
|
||||||
|
<text class="n">{{ c.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="people.length" class="sec">通讯录</view>
|
||||||
|
<view class="list">
|
||||||
|
<view v-for="p in people" :key="'p' + p.id" class="item" @click="pickPerson(p)">
|
||||||
|
<image v-if="p.avatar" class="av img" :src="p.avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="av">{{ (p.name || '?').slice(0, 1) }}</view>
|
||||||
|
<text class="n">{{ p.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FyHeader from '../../components/fy-header.vue'
|
||||||
|
import { store, previewOf } from '../../utils/store.js'
|
||||||
|
import { toast } from '../../utils/format.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { FyHeader },
|
||||||
|
data() {
|
||||||
|
return { fromSid: '', msgs: [], merge: false, q: '' }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hint() {
|
||||||
|
if (!this.msgs.length) return '没有可转发的消息'
|
||||||
|
if (this.msgs.length === 1) return '将转发:' + previewOf(this.msgs[0])
|
||||||
|
return this.merge ? ('合并转发 ' + this.msgs.length + ' 条消息') : ('逐条转发 ' + this.msgs.length + ' 条消息')
|
||||||
|
},
|
||||||
|
convs() {
|
||||||
|
const q = this.q.trim()
|
||||||
|
return store.conversations.filter((c) => {
|
||||||
|
if (!c || c.id === this.fromSid) return false
|
||||||
|
if (c.type === 'work' || c.type === 'files') return false
|
||||||
|
if (q && String(c.name || '').indexOf(q) < 0) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
people() {
|
||||||
|
const seen = new Set(
|
||||||
|
this.convs.filter((c) => c.type === 'direct' && c.peerId).map((c) => String(c.peerId))
|
||||||
|
)
|
||||||
|
const me = store.me && store.me.id
|
||||||
|
const q = this.q.trim()
|
||||||
|
return Object.values(store.people).filter((p) => {
|
||||||
|
if (!p || !p.id || p.id === me || seen.has(String(p.id))) return false
|
||||||
|
if (q && (p.name + p.department + (p.phone || '')).indexOf(q) < 0) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
const pack = store.pendingForward || {}
|
||||||
|
this.fromSid = pack.fromSid || ''
|
||||||
|
this.merge = !!pack.merge
|
||||||
|
this.msgs = pack.msgs || (pack.msg ? [pack.msg] : [])
|
||||||
|
if (!this.msgs.length) {
|
||||||
|
toast('没有可转发的消息')
|
||||||
|
setTimeout(() => uni.navigateBack(), 400)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
store.pendingForward = null
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
done() {
|
||||||
|
toast('已转发')
|
||||||
|
store.pendingForward = null
|
||||||
|
uni.navigateBack()
|
||||||
|
},
|
||||||
|
sendTo(c) {
|
||||||
|
store.forwardMany(c, this.msgs, this.merge)
|
||||||
|
this.done()
|
||||||
|
},
|
||||||
|
pickConv(c) { this.sendTo(c) },
|
||||||
|
pickPerson(p) { this.sendTo(store.openDirect(p)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search { padding: 12rpx 24rpx; }
|
||||||
|
.search input { height: 72rpx; background: #ededed; border-radius: 8rpx; padding: 0 24rpx; font-size: 26rpx; }
|
||||||
|
.snip { padding: 8rpx 32rpx 16rpx; font-size: 22rpx; color: #888; }
|
||||||
|
.sec { padding: 16rpx 32rpx 8rpx; font-size: 22rpx; color: #888; background: #f7f7f7; }
|
||||||
|
.item { display: flex; align-items: center; padding: 20rpx 24rpx; background: #fff; }
|
||||||
|
.av { width: 80rpx; height: 80rpx; border-radius: 10rpx; background: #e2e7ff; color: #004ac6; display: flex; align-items: center; justify-content: center; font-weight: 700; overflow: hidden; flex-shrink: 0; }
|
||||||
|
.av.img { width: 80rpx; height: 80rpx; }
|
||||||
|
.n { margin-left: 20rpx; font-size: 30rpx; }
|
||||||
|
.empty { padding: 80rpx; text-align: center; color: #888; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<fy-header title="群聊设置" back />
|
||||||
|
<view class="card head">
|
||||||
|
<view class="grid">
|
||||||
|
<view v-for="p in members.slice(0, 4)" :key="p.id" class="cell">{{ (p.name || '?').slice(0, 1) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta">
|
||||||
|
<text class="gn">{{ title }}</text>
|
||||||
|
<text class="gc">{{ members.length }} 人 · 协同进行中</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="cap">
|
||||||
|
<text>群成员 ({{ members.length }})</text>
|
||||||
|
</view>
|
||||||
|
<view class="mems">
|
||||||
|
<view v-for="p in members" :key="p.id" class="mem" @click="open(p)">
|
||||||
|
<view class="mav">{{ (p.name || '?').slice(0, 1) }}</view>
|
||||||
|
<text class="mn">{{ p.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row" @click="goHistory">
|
||||||
|
<text>查找聊天记录</text>
|
||||||
|
<text class="val">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row">
|
||||||
|
<text>群名称</text>
|
||||||
|
<text class="val">{{ title }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="row" @click="toggle('pinned')">
|
||||||
|
<text>置顶聊天</text>
|
||||||
|
<text class="sw" :class="{ on: pinned }">{{ pinned ? '开' : '关' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="row" @click="toggle('muted')">
|
||||||
|
<text>消息免打扰</text>
|
||||||
|
<text class="sw" :class="{ on: muted }">{{ muted ? '开' : '关' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row danger" @click="clear">清空聊天记录</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FyHeader from '../../components/fy-header.vue'
|
||||||
|
import { store } from '../../utils/store.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { FyHeader },
|
||||||
|
data() {
|
||||||
|
return { sid: '', title: '群聊', members: [], pinned: false, muted: false }
|
||||||
|
},
|
||||||
|
onLoad(q) {
|
||||||
|
this.sid = decodeURIComponent(q.sid || '')
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.pull()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
pull() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
this.title = (c && c.name) || '群聊'
|
||||||
|
this.pinned = !!(c && c.pinned)
|
||||||
|
this.muted = !!(c && c.muted)
|
||||||
|
const ids = (c && c.memberIds) || []
|
||||||
|
this.members = ids.map((id) => store.person(id) || { id, name: id, title: '', department: '' })
|
||||||
|
},
|
||||||
|
open(p) {
|
||||||
|
uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(p.id) })
|
||||||
|
},
|
||||||
|
toggle(field) {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
if (!c) return
|
||||||
|
store.setSession(c, { [field]: !c[field] })
|
||||||
|
this.pull()
|
||||||
|
},
|
||||||
|
goHistory() {
|
||||||
|
uni.navigateTo({ url: '/pages/chat/history?sid=' + encodeURIComponent(this.sid) })
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
if (!c) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '清空聊天记录',
|
||||||
|
content: '将清空本机该群的聊天记录。',
|
||||||
|
success: (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
store.clearHistory(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { margin: 24rpx; padding: 28rpx; display: flex; align-items: center; }
|
||||||
|
.grid { width: 96rpx; height: 96rpx; background: #eaedff; border-radius: 16rpx; display: flex; flex-wrap: wrap; padding: 8rpx; }
|
||||||
|
.cell { width: 36rpx; height: 36rpx; margin: 2rpx; background: #dbe1ff; color: #004ac6; font-size: 18rpx; font-weight: 700; display: flex; align-items: center; justify-content: center; border-radius: 6rpx; }
|
||||||
|
.meta { margin-left: 20rpx; }
|
||||||
|
.gn { font-size: 32rpx; font-weight: 700; display: block; }
|
||||||
|
.gc { font-size: 22rpx; color: #737686; }
|
||||||
|
.card { margin: 0 24rpx 24rpx; overflow: hidden; }
|
||||||
|
.cap { padding: 20rpx 24rpx 8rpx; font-size: 26rpx; font-weight: 700; }
|
||||||
|
.mems { display: flex; flex-wrap: wrap; padding: 8rpx 16rpx 24rpx; }
|
||||||
|
.mem { width: 20%; display: flex; flex-direction: column; align-items: center; margin-bottom: 16rpx; }
|
||||||
|
.mav { width: 80rpx; height: 80rpx; border-radius: 16rpx; background: #dbe1ff; color: #00174b; display: flex; align-items: center; justify-content: center; font-weight: 700; }
|
||||||
|
.mn { font-size: 20rpx; margin-top: 8rpx; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 28rpx 24rpx; font-size: 28rpx; border-bottom: 1rpx solid #eaedff; }
|
||||||
|
.val { color: #737686; }
|
||||||
|
.sw { color: #737686; }
|
||||||
|
.sw.on { color: #07c160; font-weight: 600; }
|
||||||
|
.danger { color: #fa5151; justify-content: center; border-bottom: none; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<fy-header title="查找聊天记录" back />
|
||||||
|
<view class="search">
|
||||||
|
<input v-model="q" placeholder="搜索文字 / 文件名" confirm-type="search" focus />
|
||||||
|
</view>
|
||||||
|
<view v-if="!q.trim()" class="empty">输入关键字查找本会话记录</view>
|
||||||
|
<view v-else-if="!hits.length" class="empty">没有找到相关记录</view>
|
||||||
|
<view v-for="m in hits" :key="m.id" class="item" @click="open(m)">
|
||||||
|
<text class="who">{{ who(m) }} · {{ timeOf(m) }}</text>
|
||||||
|
<text class="txt">{{ preview(m) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FyHeader from '../../components/fy-header.vue'
|
||||||
|
import { store, previewOf } from '../../utils/store.js'
|
||||||
|
import { fmtTime } from '../../utils/format.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { FyHeader },
|
||||||
|
data() {
|
||||||
|
return { sid: '', q: '' }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hits() {
|
||||||
|
const q = this.q.trim()
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
if (!q || !c) return []
|
||||||
|
return (c.messages || []).filter((m) => {
|
||||||
|
if (m.recalled) return false
|
||||||
|
return String(m.text || '') .indexOf(q) >= 0 || String(m.fileName || '').indexOf(q) >= 0
|
||||||
|
}).slice(-80).reverse()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(q) {
|
||||||
|
this.sid = decodeURIComponent(q.sid || '')
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
preview(m) { return previewOf(m) },
|
||||||
|
timeOf(m) { return fmtTime(m.time) },
|
||||||
|
who(m) {
|
||||||
|
if (m.mine) return (store.me && store.me.name) || '我'
|
||||||
|
const p = store.person(m.fromId)
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
return (p && p.name) || (c && c.name) || '同事'
|
||||||
|
},
|
||||||
|
open(m) {
|
||||||
|
if (m.kind === 'image' && m.fileUrl) {
|
||||||
|
uni.previewImage({ urls: [m.fileUrl], current: m.fileUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search { padding: 16rpx 24rpx; }
|
||||||
|
.search input { height: 72rpx; background: #ededed; border-radius: 8rpx; padding: 0 24rpx; }
|
||||||
|
.empty { text-align: center; color: #888; padding: 80rpx 24rpx; font-size: 26rpx; }
|
||||||
|
.item { margin: 0 24rpx; padding: 20rpx 0; border-bottom: 1rpx solid #f0f0f0; }
|
||||||
|
.who { display: block; font-size: 22rpx; color: #888; }
|
||||||
|
.txt { display: block; font-size: 28rpx; margin-top: 6rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<fy-header title="聊天信息" back />
|
||||||
|
<view v-if="peer" class="card head" @click="goPeer">
|
||||||
|
<image v-if="peer.avatar" class="av img" :src="peer.avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="av">{{ (peer.name || '?').slice(0, 1) }}</view>
|
||||||
|
<view class="meta">
|
||||||
|
<text class="n">{{ peer.name }}</text>
|
||||||
|
<text class="s">{{ peer.department }}{{ peer.title ? ' · ' + peer.title : '' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="arr">›</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row" @click="goHistory">
|
||||||
|
<text>查找聊天记录</text>
|
||||||
|
<text class="arr">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row" @click="toggle('pinned')">
|
||||||
|
<text>置顶聊天</text>
|
||||||
|
<text class="sw" :class="{ on: pinned }">{{ pinned ? '开' : '关' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="row" @click="toggle('muted')">
|
||||||
|
<text>消息免打扰</text>
|
||||||
|
<text class="sw" :class="{ on: muted }">{{ muted ? '开' : '关' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row" @click="call(false)">
|
||||||
|
<text>语音通话</text>
|
||||||
|
<text class="arr">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="row" @click="call(true)">
|
||||||
|
<text>视频通话</text>
|
||||||
|
<text class="arr">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row danger" @click="clear">清空聊天记录</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FyHeader from '../../components/fy-header.vue'
|
||||||
|
import { store } from '../../utils/store.js'
|
||||||
|
import { toast } from '../../utils/format.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { FyHeader },
|
||||||
|
data() {
|
||||||
|
return { sid: '', pinned: false, muted: false, peer: null }
|
||||||
|
},
|
||||||
|
onLoad(q) {
|
||||||
|
this.sid = decodeURIComponent(q.sid || '')
|
||||||
|
},
|
||||||
|
onShow() { this.pull() },
|
||||||
|
methods: {
|
||||||
|
pull() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
this.pinned = !!(c && c.pinned)
|
||||||
|
this.muted = !!(c && c.muted)
|
||||||
|
this.peer = c && c.peerId ? store.person(c.peerId) : null
|
||||||
|
if (!this.peer && c) this.peer = { name: c.name, avatar: c.avatar, department: '', title: '' }
|
||||||
|
},
|
||||||
|
toggle(field) {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
if (!c) return
|
||||||
|
store.setSession(c, { [field]: !c[field] })
|
||||||
|
this.pull()
|
||||||
|
},
|
||||||
|
goPeer() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
if (c && c.peerId) uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(c.peerId) })
|
||||||
|
},
|
||||||
|
goHistory() {
|
||||||
|
uni.navigateTo({ url: '/pages/chat/history?sid=' + encodeURIComponent(this.sid) })
|
||||||
|
},
|
||||||
|
call(video) {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
if (!c || !c.peerId) return
|
||||||
|
store.startCall(c.peerId, { video }).catch((e) => toast((e && e.message) || '无法发起通话'))
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
const c = store.findConv(this.sid)
|
||||||
|
if (!c) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '清空聊天记录',
|
||||||
|
content: '将清空本机该会话的聊天记录。',
|
||||||
|
success: (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
store.clearHistory(c)
|
||||||
|
toast('已清空')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { margin: 24rpx; padding: 28rpx; display: flex; align-items: center; }
|
||||||
|
.av { width: 96rpx; height: 96rpx; border-radius: 12rpx; background: #004ac6; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 700; overflow: hidden; }
|
||||||
|
.av.img { width: 96rpx; height: 96rpx; }
|
||||||
|
.meta { flex: 1; margin-left: 20rpx; }
|
||||||
|
.n { display: block; font-size: 32rpx; font-weight: 700; }
|
||||||
|
.s { font-size: 22rpx; color: #737686; }
|
||||||
|
.arr { color: #c3c6d7; }
|
||||||
|
.card { margin: 0 24rpx 24rpx; overflow: hidden; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 28rpx 24rpx; font-size: 28rpx; border-bottom: 1rpx solid #f0f0f0; }
|
||||||
|
.sw { color: #737686; }
|
||||||
|
.sw.on { color: #07c160; font-weight: 600; }
|
||||||
|
.danger { color: #fa5151; justify-content: center; border-bottom: none; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<fy-header title="工作通知" back />
|
||||||
|
<view class="bar">
|
||||||
|
<text>审批 · 协同 · 投标 · 用章 · 公告</text>
|
||||||
|
<text class="badge">{{ items.length }} 项待办</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="!items.length" class="empty">暂无待办通知</view>
|
||||||
|
<view v-for="t in items" :key="t.no || t.id" class="card item" @click="open(t)">
|
||||||
|
<view class="stripe" />
|
||||||
|
<view class="body">
|
||||||
|
<view class="top">
|
||||||
|
<text class="kind">{{ t.typeLabel || t.tag || t.type || '待办' }}</text>
|
||||||
|
<text class="time">{{ t.createdAt || t.time || '' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="title">{{ t.title || t.summary || t.no }}</text>
|
||||||
|
<text class="sub">{{ t.submitter || t.applicant || '' }}{{ t.dept ? ' · ' + t.dept : '' }}</text>
|
||||||
|
<view class="acts">
|
||||||
|
<text class="go">去办理</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FyHeader from '../../components/fy-header.vue'
|
||||||
|
import { store } from '../../utils/store.js'
|
||||||
|
import { asList, fmtTime } from '../../utils/format.js'
|
||||||
|
import { oaGet } from '../../utils/api.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { FyHeader },
|
||||||
|
data() {
|
||||||
|
return { items: [] }
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const page = await oaGet('/workflow/requests?tab=pending&page=1&size=50', store.token)
|
||||||
|
this.items = asList(page && page.records ? page.records : page).map((t) => ({
|
||||||
|
...t,
|
||||||
|
createdAt: fmtTime(t.createdAt || t.time)
|
||||||
|
}))
|
||||||
|
} catch (_) {
|
||||||
|
this.items = store.todos || []
|
||||||
|
}
|
||||||
|
const conv = store.conversations.find((c) => c.type === 'work')
|
||||||
|
if (conv) conv.unread = 0
|
||||||
|
},
|
||||||
|
open(t) {
|
||||||
|
const no = t.no || t.id || ''
|
||||||
|
if (String(t.source || t.type || '') === 'work' || t.taskId) {
|
||||||
|
uni.navigateTo({ url: '/pages/coop/detail?id=' + encodeURIComponent(t.taskId || t.id || no) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(no) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bar { margin: 16rpx 24rpx; background: #f2f3ff; border-radius: 16rpx; padding: 16rpx 20rpx; display: flex; justify-content: space-between; font-size: 22rpx; color: #434655; }
|
||||||
|
.item { margin: 0 24rpx 20rpx; display: flex; overflow: hidden; }
|
||||||
|
.stripe { width: 8rpx; background: #2563eb; }
|
||||||
|
.body { flex: 1; padding: 24rpx; }
|
||||||
|
.top { display: flex; justify-content: space-between; }
|
||||||
|
.kind { font-size: 28rpx; font-weight: 700; }
|
||||||
|
.time { font-size: 22rpx; color: #737686; }
|
||||||
|
.title { display: block; font-size: 28rpx; font-weight: 600; margin: 12rpx 0 6rpx; }
|
||||||
|
.sub { font-size: 22rpx; color: #737686; }
|
||||||
|
.acts { margin-top: 16rpx; }
|
||||||
|
.go { color: #2563eb; font-size: 24rpx; font-weight: 600; }
|
||||||
|
.empty { text-align: center; color: #737686; padding: 80rpx; }
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
|
||||||
|
<title>通话</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #0f172a; color: #fff; font-family: sans-serif; overflow: hidden; }
|
||||||
|
#remote { width: 100%; height: 100%; object-fit: cover; background: #000; }
|
||||||
|
#local { position: absolute; right: 12px; bottom: 88px; width: 112px; height: 150px; object-fit: cover; border-radius: 8px; background: #111; border: 1px solid rgba(255,255,255,.2); }
|
||||||
|
#hint { position: absolute; left: 16px; top: 16px; font-size: 14px; }
|
||||||
|
#err { position: absolute; left: 16px; right: 16px; top: 48%; text-align: center; color: #fecaca; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video id="remote" autoplay playsinline></video>
|
||||||
|
<video id="local" autoplay muted playsinline></video>
|
||||||
|
<div id="hint">正在连接 LiveKit…</div>
|
||||||
|
<div id="err"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.15.4/dist/livekit-client.umd.js"></script>
|
||||||
|
<script>
|
||||||
|
const q = new URLSearchParams(location.search)
|
||||||
|
const url = q.get('url') || ''
|
||||||
|
const token = q.get('token') || ''
|
||||||
|
const video = q.get('video') !== '0'
|
||||||
|
const hint = document.getElementById('hint')
|
||||||
|
const err = document.getElementById('err')
|
||||||
|
const remote = document.getElementById('remote')
|
||||||
|
const local = document.getElementById('local')
|
||||||
|
if (!video) local.style.display = 'none'
|
||||||
|
|
||||||
|
async function go() {
|
||||||
|
if (!url || !token) {
|
||||||
|
err.textContent = '缺少 LiveKit 地址或凭证'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const LK = window.LivekitClient || window.livekit
|
||||||
|
if (!LK || !LK.Room) {
|
||||||
|
err.textContent = 'LiveKit 脚本未加载'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const room = new LK.Room({ adaptiveStream: true, dynacast: true })
|
||||||
|
room.on(LK.RoomEvent.TrackSubscribed, (track) => {
|
||||||
|
if (track.kind === LK.Track.Kind.Video || track.kind === LK.Track.Kind.Audio) {
|
||||||
|
track.attach(remote)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
room.on(LK.RoomEvent.Disconnected, () => { hint.textContent = '已断开' })
|
||||||
|
try {
|
||||||
|
await room.connect(url, token)
|
||||||
|
hint.textContent = '通话中'
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(true)
|
||||||
|
if (video) {
|
||||||
|
await room.localParticipant.setCameraEnabled(true)
|
||||||
|
const pubs = Array.from(room.localParticipant.videoTrackPublications.values())
|
||||||
|
if (pubs[0] && pubs[0].track) pubs[0].track.attach(local)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = (e && e.message) || String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 225 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 197 B |
|
After Width: | Height: | Size: 197 B |
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 167 B |
@@ -0,0 +1,6 @@
|
|||||||
|
$fy-brand: #2563eb;
|
||||||
|
$fy-brand-deep: #004ac6;
|
||||||
|
$fy-surface: #faf8ff;
|
||||||
|
$fy-ink: #131b2e;
|
||||||
|
$fy-muted: #737686;
|
||||||
|
$fy-line: #eaedff;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { IM_HTTP, OA_BASE } from './config.js'
|
||||||
|
|
||||||
|
function unwrap(json) {
|
||||||
|
if (!json || typeof json !== 'object') return json
|
||||||
|
const code = json.code
|
||||||
|
if (code != null && code !== 0 && code !== 200) {
|
||||||
|
const err = new Error(json.message || '请求失败')
|
||||||
|
err.status = code
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return Object.prototype.hasOwnProperty.call(json, 'data') ? json.data : json
|
||||||
|
}
|
||||||
|
|
||||||
|
export function request(path, { method = 'GET', data, token } = {}) {
|
||||||
|
const url = path.startsWith('http') ? path : `${IM_HTTP}${path.startsWith('/') ? path : '/' + path}`
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
data,
|
||||||
|
timeout: 20000,
|
||||||
|
header: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(data != null ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
success(res) {
|
||||||
|
const body = res.data
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
const msg = body && body.message ? body.message : `HTTP ${res.statusCode}`
|
||||||
|
const err = new Error(msg)
|
||||||
|
err.status = res.statusCode
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(unwrap(body))
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail(e) {
|
||||||
|
reject(new Error((e && e.errMsg) || '网络异常'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginOptions() {
|
||||||
|
return request('/api/im/sms/login-options')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(body) {
|
||||||
|
return request('/api/im/login', { method: 'POST', data: body })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendLoginCode(account) {
|
||||||
|
return request('/api/im/sms/login-code', { method: 'POST', data: { account } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePassword(body, token) {
|
||||||
|
return request('/api/im/change-password', { method: 'POST', data: body, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bootstrap(token) {
|
||||||
|
return request('/api/im/bootstrap', { token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messages(sessionId, token) {
|
||||||
|
return request(`/api/im/messages?sessionId=${encodeURIComponent(sessionId)}`, { token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markRead(sessionId, token) {
|
||||||
|
return request('/api/im/read', { method: 'POST', data: { sessionId }, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroup(body, token) {
|
||||||
|
return request('/api/im/groups', { method: 'POST', data: body, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionAction(body, token) {
|
||||||
|
return request('/api/im/sessions/action', { method: 'POST', data: body, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recall(body, token) {
|
||||||
|
return request('/api/im/recall', { method: 'POST', data: body, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function livekitToken(body, token) {
|
||||||
|
return request('/api/im/livekit/token', { method: 'POST', data: body, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oaGet(path, token) {
|
||||||
|
return request(`/api/im/oa${path}`, { token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oaPost(path, data, token) {
|
||||||
|
return request(`/api/im/oa${path}`, { method: 'POST', data, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oaPut(path, data, token) {
|
||||||
|
return request(`/api/im/oa${path}`, { method: 'PUT', data, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absUrl(path) {
|
||||||
|
if (!path) return ''
|
||||||
|
const s = String(path)
|
||||||
|
if (s.startsWith('http') || s.startsWith('data:') || s.startsWith('blob:')) return s
|
||||||
|
// 证件照 / 附件票在 OA 域名,不能拼到 /im 后面
|
||||||
|
if (
|
||||||
|
s.startsWith('/api/org/') ||
|
||||||
|
s.startsWith('/api/files') ||
|
||||||
|
s.startsWith('/api/auth/') ||
|
||||||
|
s.startsWith('/api/profile/') ||
|
||||||
|
s.startsWith('/api/hr/')
|
||||||
|
) {
|
||||||
|
return `${OA_BASE}${s}`
|
||||||
|
}
|
||||||
|
const base = IM_HTTP || OA_BASE
|
||||||
|
return s.startsWith('/') ? `${base}${s}` : `${base}/${s}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 证件照完整地址:优先 avatar 票,否则用用户 ID 拼 org/photo(需票,一般走 refreshPeoplePhotos) */
|
||||||
|
export function photoUrl(raw) {
|
||||||
|
if (!raw) return ''
|
||||||
|
if (typeof raw === 'string') return absUrl(raw)
|
||||||
|
const av = raw.avatar || raw.photo || raw.faceUrl || ''
|
||||||
|
if (av) return absUrl(av)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
export const APPLY_KINDS = [
|
||||||
|
{ kind: 'expense', title: '报销申请', group: '费用', hint: '一张报销单可添加多张发票', multiInvoice: true, icon: 'receipt_long' },
|
||||||
|
{
|
||||||
|
kind: 'loan', title: '借支申请', group: '资金', hint: '因公业务需向公司预支资金', icon: 'payments',
|
||||||
|
fields: [
|
||||||
|
{ key: 'amount', label: '借支金额', type: 'money', placeholder: '请输入借支金额(元)' },
|
||||||
|
{ key: 'costType', label: '费用归属', type: 'select', options: ['投标费用', '正式项目', '部门费用', '行政'] },
|
||||||
|
{ key: 'project', label: '关联项目', type: 'text', placeholder: '投标案件或正式项目名称' },
|
||||||
|
{ key: 'days', label: '预计归还期限', type: 'text', placeholder: '例如:30 天' },
|
||||||
|
{ key: 'reason', label: '借支用途', type: 'textarea', placeholder: '请说明借支用途' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'leave', title: '请假申请', group: '休假', hint: '年假、事假、病假等', icon: 'date_range',
|
||||||
|
fields: [
|
||||||
|
{ key: 'leaveType', label: '假期类型', type: 'select', options: ['年假', '事假', '病假', '婚假', '产假/陪产假', '调休'] },
|
||||||
|
{ key: 'start', label: '开始时间', type: 'datetime' },
|
||||||
|
{ key: 'end', label: '结束时间', type: 'datetime' },
|
||||||
|
{ key: 'days', label: '请假时长(天)', type: 'number', placeholder: '0.5 / 1 / 2' },
|
||||||
|
{ key: 'reason', label: '请假事由', type: 'textarea', placeholder: '请说明请假原因' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'trip', title: '出差申请', group: '差旅', hint: '跨城市差旅报备', icon: 'flight_takeoff',
|
||||||
|
fields: [
|
||||||
|
{ key: 'city', label: '出差地点', type: 'text', placeholder: '例如:上海 · 浦东' },
|
||||||
|
{ key: 'start', label: '出发日期', type: 'date' },
|
||||||
|
{ key: 'end', label: '返回日期', type: 'date' },
|
||||||
|
{ key: 'reason', label: '出差事由', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'procurement', title: '采购申请', group: '采购', hint: '设备、物资、服务采购', icon: 'shopping_cart',
|
||||||
|
fields: [
|
||||||
|
{ key: 'goods', label: '采购内容', type: 'text' },
|
||||||
|
{ key: 'amount', label: '预算金额', type: 'money' },
|
||||||
|
{ key: 'needDate', label: '期望到货日期', type: 'date' },
|
||||||
|
{ key: 'supplier', label: '意向供应商', type: 'text', optional: true },
|
||||||
|
{ key: 'reason', label: '采购理由', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'overtime', title: '加班申请', group: '考勤', hint: '加班时长申报', icon: 'schedule',
|
||||||
|
fields: [
|
||||||
|
{ key: 'date', label: '加班日期', type: 'date' },
|
||||||
|
{ key: 'hours', label: '加班时长(小时)', type: 'number' },
|
||||||
|
{ key: 'reason', label: '申请说明', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'outing', title: '外出申请', group: '考勤', hint: '工作期间临时外出', icon: 'near_me',
|
||||||
|
fields: [
|
||||||
|
{ key: 'place', label: '外出地点', type: 'text' },
|
||||||
|
{ key: 'start', label: '开始时间', type: 'datetime' },
|
||||||
|
{ key: 'end', label: '结束时间', type: 'datetime' },
|
||||||
|
{ key: 'reason', label: '申请说明', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'card', title: '补卡申请', group: '考勤', hint: '漏打卡补卡说明', icon: 'edit_calendar',
|
||||||
|
fields: [
|
||||||
|
{ key: 'punchDate', label: '补卡日期', type: 'date' },
|
||||||
|
{ key: 'punchKind', label: '补卡类型', type: 'select', options: ['上班', '下班'] },
|
||||||
|
{ key: 'reason', label: '申请说明', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'transfer', title: '转岗申请', group: '人事', hint: '岗位 / 部门调动', icon: 'swap_horiz',
|
||||||
|
fields: [
|
||||||
|
{ key: 'fromDept', label: '原部门', type: 'text' },
|
||||||
|
{ key: 'toDept', label: '拟调入部门', type: 'text' },
|
||||||
|
{ key: 'newTitle', label: '拟任岗位', type: 'text', optional: true },
|
||||||
|
{ key: 'reason', label: '申请说明', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'regularize', title: '转正申请', group: '人事', hint: '实习/试用期满转正', onlyRegularize: true, icon: 'how_to_reg',
|
||||||
|
fields: [
|
||||||
|
{ key: 'currentStatus', label: '当前身份', type: 'select', options: ['实习', '试用'] },
|
||||||
|
{ key: 'hiredAt', label: '入职日期', type: 'date' },
|
||||||
|
{ key: 'summary', label: '试用/实习工作总结', type: 'textarea' },
|
||||||
|
{ key: 'nextPlan', label: '转正后工作计划', type: 'textarea' },
|
||||||
|
{ key: 'reason', label: '转正说明', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'resign', title: '离职申请', group: '人事', hint: '本人提前申请离职', icon: 'logout',
|
||||||
|
fields: [
|
||||||
|
{ key: 'lastDay', label: '最后工作日', type: 'date' },
|
||||||
|
{ key: 'reason', label: '离职原因', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'layoff', title: '裁员', group: '人事', hint: '人事或总监发起', onlyLayoff: true, icon: 'person_off',
|
||||||
|
fields: [
|
||||||
|
{ key: 'targetUsername', label: '被裁员工', type: 'select' },
|
||||||
|
{ key: 'lastDay', label: '最后工作日', type: 'date' },
|
||||||
|
{ key: 'reason', label: '裁员说明', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'seal', title: '用章申请', group: '印章', hint: '选择鲁兵或风影', icon: 'approval',
|
||||||
|
fields: [
|
||||||
|
{ key: 'sealCompany', label: '用章主体', type: 'select', options: ['鲁兵', '风影'] },
|
||||||
|
{ key: 'sealType', label: '申请印章', type: 'select', options: ['法人章', '公章', '法人章和公章'] },
|
||||||
|
{ key: 'reason', label: '申请说明', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'receive', title: '领用申请', group: '物资', hint: '办公物资与耗材领用', icon: 'inventory_2',
|
||||||
|
fields: [
|
||||||
|
{ key: 'goods', label: '领用物品', type: 'text' },
|
||||||
|
{ key: 'qty', label: '数量', type: 'text' },
|
||||||
|
{ key: 'reason', label: '申请说明', type: 'textarea' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function applyKindById(kind) {
|
||||||
|
return APPLY_KINDS.find((k) => k.kind === kind) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFixedChain(kind) {
|
||||||
|
return kind === 'expense' || kind === 'loan' || kind === 'procurement' || kind === 'seal'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canApplyRegularize(status) {
|
||||||
|
const s = String(status || '').trim()
|
||||||
|
return s === '实习' || s === '试用' || s === 'INTERN' || s === 'PROBATION'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canApplyLayoff({ isSuper, role }) {
|
||||||
|
if (isSuper) return true
|
||||||
|
const r = String(role || '')
|
||||||
|
return r === '企业负责人' || r.includes('总监') || r.includes('负责人')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleApplyKinds({ employeeStatus, isSuper, role }) {
|
||||||
|
return APPLY_KINDS.filter((t) => {
|
||||||
|
if (t.onlyRegularize && !canApplyRegularize(employeeStatus)) return false
|
||||||
|
if (t.onlyLayoff && !canApplyLayoff({ isSuper, role })) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupedApplyKinds(kinds) {
|
||||||
|
const groups = []
|
||||||
|
const map = {}
|
||||||
|
kinds.forEach((k) => {
|
||||||
|
if (!map[k.group]) {
|
||||||
|
map[k.group] = { category: k.group, items: [] }
|
||||||
|
groups.push(map[k.group])
|
||||||
|
}
|
||||||
|
map[k.group].items.push(k)
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApplyPayload({ spec, title, values, invoices, expenseRemark, approverUsername, suggestedUsername, me, layoffTargetName, attachments }) {
|
||||||
|
const form = { reason: values.reason || '', ...values }
|
||||||
|
if (Array.isArray(attachments) && attachments.length) form.attachments = attachments
|
||||||
|
let amount = null
|
||||||
|
const moneyField = (spec.fields || []).find((f) => f.type === 'money' && !f.optional)
|
||||||
|
if (moneyField) amount = Number(values[moneyField.key]) || null
|
||||||
|
if (spec.kind === 'resign' && me) {
|
||||||
|
form.targetUsername = me.username
|
||||||
|
form.targetName = me.name
|
||||||
|
}
|
||||||
|
if (spec.kind === 'layoff') form.targetName = layoffTargetName || ''
|
||||||
|
if (spec.multiInvoice) {
|
||||||
|
const rows = (invoices || []).map((r, i) => ({
|
||||||
|
seq: i + 1,
|
||||||
|
ticketType: r.ticketType,
|
||||||
|
amount: Number(r.amount) || 0,
|
||||||
|
invoiceNo: r.invoiceNo || '',
|
||||||
|
date: r.date || '',
|
||||||
|
note: r.note || '',
|
||||||
|
files: Array.isArray(r.files) ? r.files : [],
|
||||||
|
proxyPay: r.proxyPay === true,
|
||||||
|
proxyPayee: r.proxyPayee || '',
|
||||||
|
proxyPayeeName: r.proxyPayeeName || ''
|
||||||
|
}))
|
||||||
|
amount = rows.reduce((s, r) => s + r.amount, 0)
|
||||||
|
form.remark = expenseRemark || ''
|
||||||
|
form.reason = expenseRemark || rows.map((r) => r.note).filter(Boolean).join(';')
|
||||||
|
form.invoices = rows
|
||||||
|
form.receipts = rows.length
|
||||||
|
}
|
||||||
|
const approver = isFixedChain(spec.kind)
|
||||||
|
? (suggestedUsername || approverUsername)
|
||||||
|
: approverUsername
|
||||||
|
return {
|
||||||
|
type: spec.kind,
|
||||||
|
typeLabel: spec.title,
|
||||||
|
title: String(title || '').trim(),
|
||||||
|
amount: amount && amount > 0 ? amount : null,
|
||||||
|
reason: form.reason || '',
|
||||||
|
approverUsername: approver,
|
||||||
|
form
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 工作台入口目录:只描述「有这个能力时显示什么」。
|
||||||
|
* 谁能看见由 /auth/my-perms 决定,这里不写人、不写岗位。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PERSONAL_APPS = [
|
||||||
|
{ id: 'clock', title: '考勤打卡', icon: '📍', menu: 'personal', action: 'punch', url: '/pages/tabs/clock' },
|
||||||
|
{ id: 'todo', title: '待办审批', icon: '☑', menu: 'personal', action: 'approve', url: '/pages/todo/list', badge: 'todo' },
|
||||||
|
{ id: 'apply', title: '发起申请', icon: '✎', menu: 'personal', action: 'apply', url: '/pages/apply/list' },
|
||||||
|
{ id: 'expense', title: '我的报销', icon: '🧾', menu: 'personal', action: 'view', url: '/pages/bills/list?type=expense&title=我的报销' },
|
||||||
|
{ id: 'loan', title: '我的借支', icon: '¥', menu: 'personal', action: 'view', url: '/pages/bills/list?type=loan&title=我的借支' },
|
||||||
|
{ id: 'report', title: '工作汇报', icon: '▤', menu: 'personal', action: 'report', url: '/pages/reports/list' },
|
||||||
|
{ id: 'salary', title: '工资条', icon: '💳', menu: 'personal', action: 'view', url: '/pages/me/payslip' },
|
||||||
|
{ id: 'perf', title: '我的绩效', icon: '↗', menu: 'personal', action: 'view', url: '/pages/me/performance' },
|
||||||
|
{ id: 'archive', title: '我的资料', icon: '👤', menu: 'personal', action: 'view', url: '/pages/me/archive' },
|
||||||
|
{ id: 'notice', title: '公告', icon: '📢', menu: 'personal', action: 'view', url: '/pages/notice/list' },
|
||||||
|
{ id: 'coop', title: '工作协同', icon: '🤝', menu: 'personal', action: 'view', url: '/pages/coop/list' },
|
||||||
|
{ id: 'seals', title: '我的用章', icon: '©', menu: 'personal', action: 'view', url: '/pages/bills/list?type=seal&title=我的用章' },
|
||||||
|
{ id: 'contacts', title: '通讯录', icon: '☎', menu: 'personal', action: 'view', url: '/pages/tabs/contacts' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ERP_MODULES = [
|
||||||
|
{ id: 'bidding', title: '投标管理', icon: '◎', menu: 'bidding', action: 'view', url: '/pages/erp/hub?id=bidding' },
|
||||||
|
{ id: 'project', title: '项目管理', icon: '▣', menu: 'project', action: 'view', url: '/pages/erp/hub?id=project' },
|
||||||
|
{ id: 'finance', title: '财务管理', icon: '◈', menu: 'finance', action: 'view', url: '/pages/erp/hub?id=finance' },
|
||||||
|
{ id: 'hr', title: '人事管理', icon: '☰', menu: 'hr', action: 'view', url: '/pages/erp/hub?id=hr' },
|
||||||
|
{ id: 'contracts', title: '合同管理', icon: '▤', menu: 'contracts', action: 'view', url: '/pages/erp/hub?id=contracts' },
|
||||||
|
{ id: 'seals', title: '证照用章', icon: '◆', menu: 'seals', action: 'view', url: '/pages/erp/hub?id=seals' },
|
||||||
|
{ id: 'crm', title: '客户管理', icon: '◇', menu: 'crm', action: 'view', url: '/pages/erp/hub?id=crm' },
|
||||||
|
{ id: 'system', title: '系统管理', icon: '⚙', menu: 'system', action: 'view', url: '/pages/erp/hub?id=system' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ERP_TABS = {
|
||||||
|
bidding: [
|
||||||
|
{ id: 'filter', title: '项目筛选', action: 'view' },
|
||||||
|
{ id: 'special-filter', title: '特殊筛选', action: 'specialFilter' },
|
||||||
|
{ id: 'doc-make', title: '标书制作', action: 'view' },
|
||||||
|
{ id: 'special-make', title: '特殊制作', action: 'specialMake' },
|
||||||
|
{ id: 'execution', title: '投标执行', action: 'view' },
|
||||||
|
{ id: 'results', title: '中标结果', action: 'view' },
|
||||||
|
{ id: 'abort', title: '落标归档', action: 'view' },
|
||||||
|
{ id: 'stats', title: '项目统计', action: 'view' }
|
||||||
|
],
|
||||||
|
project: [
|
||||||
|
{ id: 'list', title: '项目列表' },
|
||||||
|
{ id: 'progress', title: '项目进度' },
|
||||||
|
{ id: 'workhours', title: '工时记录', action: 'hours' },
|
||||||
|
{ id: 'accept', title: '项目验收', action: 'accept' }
|
||||||
|
],
|
||||||
|
finance: [
|
||||||
|
{ id: 'ledger', title: '总账管理' },
|
||||||
|
{ id: 'expense', title: '报销记录' },
|
||||||
|
{ id: 'loan', title: '借支记录' },
|
||||||
|
{ id: 'tender-bond', title: '投标保证金' },
|
||||||
|
{ id: 'invoice', title: '开票记录' },
|
||||||
|
{ id: 'receivable', title: '应收账款' }
|
||||||
|
],
|
||||||
|
hr: [
|
||||||
|
{ id: 'staff', title: '员工管理' },
|
||||||
|
{ id: 'org', title: '组织管理' },
|
||||||
|
{ id: 'attendance', title: '考勤管理' },
|
||||||
|
{ id: 'salary', title: '薪酬核算' },
|
||||||
|
{ id: 'notice', title: '公告发布', action: 'notice' }
|
||||||
|
],
|
||||||
|
contracts: [
|
||||||
|
{ id: 'bid', title: '投标合同' },
|
||||||
|
{ id: 'direct', title: '直签合同' },
|
||||||
|
{ id: 'other', title: '其他合同' }
|
||||||
|
],
|
||||||
|
seals: [
|
||||||
|
{ id: 'qualification', title: '资质管理' },
|
||||||
|
{ id: 'borrow', title: '证照借用' },
|
||||||
|
{ id: 'logs', title: '用章记录' },
|
||||||
|
{ id: 'manage', title: '印章管理' }
|
||||||
|
],
|
||||||
|
crm: [
|
||||||
|
{ id: 'list', title: '客户列表' },
|
||||||
|
{ id: 'opportunity', title: '商机跟进', action: 'follow' },
|
||||||
|
{ id: 'pool', title: '公海池', action: 'claim' }
|
||||||
|
],
|
||||||
|
system: [
|
||||||
|
{ id: 'security', title: '权限设置', action: 'grant' },
|
||||||
|
{ id: 'attend', title: '考勤规则', action: 'attend' },
|
||||||
|
{ id: 'logs', title: '操作日志' },
|
||||||
|
{ id: 'online', title: '在线人数' }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const OA_BASE = 'https://oa.fysxkj.com'
|
||||||
|
export const IM_HTTP = 'https://oa.fysxkj.com/im'
|
||||||
|
export const IM_WS = 'wss://oa.fysxkj.com/im/websocket'
|
||||||
|
export const LIVEKIT_WS = 'wss://oa.fysxkj.com/livekit'
|
||||||
|
export const BRAND = '风影随行通讯'
|
||||||
|
|
||||||
|
export function livekitPublicUrl(raw) {
|
||||||
|
const trimmed = String(raw || '').trim().replace(/\/$/, '')
|
||||||
|
if (!trimmed) return LIVEKIT_WS
|
||||||
|
try {
|
||||||
|
const u = new URL(trimmed)
|
||||||
|
if (u.port === '7880' && (u.hostname === 'oa.fysxkj.com' || u.hostname === '47.96.23.244')) {
|
||||||
|
return LIVEKIT_WS
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function callRoomOf(a, b) {
|
||||||
|
return 'call-' + [String(a), String(b)].sort().join('-')
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { OA_BASE, IM_HTTP } from './config.js'
|
||||||
|
import { toast } from './format.js'
|
||||||
|
|
||||||
|
export function fileId(file) {
|
||||||
|
if (file == null) return ''
|
||||||
|
if (typeof file === 'string' || typeof file === 'number') {
|
||||||
|
const s = String(file)
|
||||||
|
return !s || s === '0' || s === 'null' ? '' : s
|
||||||
|
}
|
||||||
|
const id = file.id ?? file.fileId
|
||||||
|
if (id == null || id === '' || id === 0 || id === '0' || id === 'null') return ''
|
||||||
|
return String(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileName(file, fallback = '附件') {
|
||||||
|
if (!file || typeof file !== 'object') return fallback
|
||||||
|
return String(file.name || file.fileName || file.originalName || fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageFile(file) {
|
||||||
|
const name = fileName(file, '')
|
||||||
|
const type = String((file && file.contentType) || '').toLowerCase()
|
||||||
|
return type.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|heic)$/i.test(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapUpload(body) {
|
||||||
|
if (!body || typeof body !== 'object') return body
|
||||||
|
if (body.code != null && body.code !== 0 && body.code !== 200) {
|
||||||
|
throw new Error(body.message || '上传失败')
|
||||||
|
}
|
||||||
|
return Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadLocal(filePath, token, { kind = 'attach', name } = {}) {
|
||||||
|
const urls = [`${OA_BASE}/api/files`, `${IM_HTTP}/api/im/oa/files`]
|
||||||
|
const send = (url) => new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
formData: { kind, ...(kind === 'im' || kind === 'IM' ? { bizType: 'im' } : {}) },
|
||||||
|
header: token ? { Authorization: 'Bearer ' + token } : {},
|
||||||
|
timeout: 120000,
|
||||||
|
success(res) {
|
||||||
|
let body = res.data
|
||||||
|
try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) {}
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
reject(new Error((body && body.message) || `上传失败 ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = unwrapUpload(body)
|
||||||
|
const id = fileId(data)
|
||||||
|
if (!id) throw new Error((body && body.message) || '上传未返回文件')
|
||||||
|
resolve({
|
||||||
|
id,
|
||||||
|
name: (data && data.name) || name || '附件',
|
||||||
|
size: (data && data.size) || 0,
|
||||||
|
contentType: (data && data.contentType) || ''
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail(e) {
|
||||||
|
reject(new Error((e && e.errMsg) || '上传失败'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return send(urls[0]).catch(() => send(urls[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileTicket(id, token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const urls = [`${OA_BASE}/api/files/${id}/ticket`, `${IM_HTTP}/api/im/oa/files/${id}/ticket`]
|
||||||
|
const post = (url, next) => {
|
||||||
|
uni.request({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
header: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(token ? { Authorization: 'Bearer ' + token } : {})
|
||||||
|
},
|
||||||
|
success(res) {
|
||||||
|
const body = res.data
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
if (next) return next()
|
||||||
|
reject(new Error((body && body.message) || '无法预览'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = body && body.data !== undefined ? body.data : body
|
||||||
|
if (data && data.url) {
|
||||||
|
resolve(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next) return next()
|
||||||
|
reject(new Error('无法预览'))
|
||||||
|
},
|
||||||
|
fail() {
|
||||||
|
if (next) next()
|
||||||
|
else reject(new Error('无法预览'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
post(urls[0], () => post(urls[1], null))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFile(file, token) {
|
||||||
|
const id = fileId(file)
|
||||||
|
if (!id) {
|
||||||
|
toast('文件未迁移或无法预览')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
uni.showLoading({ title: '打开附件…', mask: true })
|
||||||
|
const ticket = await fileTicket(id, token)
|
||||||
|
const url = ticket.url
|
||||||
|
uni.hideLoading()
|
||||||
|
if (isImageFile(file)) {
|
||||||
|
uni.previewImage({ urls: [url], current: url })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.downloadFile({
|
||||||
|
url,
|
||||||
|
header: token ? { Authorization: 'Bearer ' + token } : {},
|
||||||
|
success(res) {
|
||||||
|
if (res.statusCode === 200 && res.tempFilePath) {
|
||||||
|
uni.openDocument({
|
||||||
|
filePath: res.tempFilePath,
|
||||||
|
showMenu: true,
|
||||||
|
fail() {
|
||||||
|
plus.runtime.openURL(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast('下载失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail() {
|
||||||
|
try { plus.runtime.openURL(url) } catch (_) { toast('无法打开附件') }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading()
|
||||||
|
toast((e && e.message) || '无法预览附件')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadPaths(paths, token, kind) {
|
||||||
|
return Promise.all(paths.map((p) => uploadLocal(p, token, { kind })))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickAndUpload(token, kind = 'attach') {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fromImages = () => {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 6,
|
||||||
|
sizeType: ['compressed', 'original'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
success(res) {
|
||||||
|
const paths = res.tempFilePaths || []
|
||||||
|
if (!paths.length) return resolve([])
|
||||||
|
uni.showLoading({ title: '上传中…', mask: true })
|
||||||
|
uploadPaths(paths, token, kind)
|
||||||
|
.then((rows) => { uni.hideLoading(); resolve(rows) })
|
||||||
|
.catch((e) => { uni.hideLoading(); reject(e) })
|
||||||
|
},
|
||||||
|
fail() { resolve([]) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const fromFiles = () => {
|
||||||
|
const chooser = typeof uni.chooseFile === 'function' ? uni.chooseFile : null
|
||||||
|
if (!chooser) return fromImages()
|
||||||
|
chooser({
|
||||||
|
count: 6,
|
||||||
|
type: 'all',
|
||||||
|
extension: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx', '.xls', '.xlsx'],
|
||||||
|
success(res) {
|
||||||
|
const files = res.tempFiles || []
|
||||||
|
const paths = files.map((f) => f.path || f.tempFilePath).filter(Boolean)
|
||||||
|
if (!paths.length) return resolve([])
|
||||||
|
uni.showLoading({ title: '上传中…', mask: true })
|
||||||
|
uploadPaths(paths, token, kind)
|
||||||
|
.then((rows) => { uni.hideLoading(); resolve(rows) })
|
||||||
|
.catch((e) => { uni.hideLoading(); reject(e) })
|
||||||
|
},
|
||||||
|
fail() { resolve([]) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: ['拍照或相册', '选择文件'],
|
||||||
|
success(res) {
|
||||||
|
if (res.tapIndex === 0) fromImages()
|
||||||
|
else fromFiles()
|
||||||
|
},
|
||||||
|
fail() { resolve([]) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectFiles(row) {
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
const push = (f) => {
|
||||||
|
if (!f) return
|
||||||
|
const id = fileId(f)
|
||||||
|
const key = id || fileName(f)
|
||||||
|
if (!key || seen.has(key)) return
|
||||||
|
seen.add(key)
|
||||||
|
out.push(typeof f === 'object' ? f : { id })
|
||||||
|
}
|
||||||
|
const walk = (v) => {
|
||||||
|
if (!v) return
|
||||||
|
if (Array.isArray(v)) v.forEach(walk)
|
||||||
|
else if (typeof v === 'object') {
|
||||||
|
if (fileId(v) || v.name) push(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!row || typeof row !== 'object') return out
|
||||||
|
walk(row.files)
|
||||||
|
walk(row.attachments)
|
||||||
|
walk(row.invoiceFiles)
|
||||||
|
walk(row.receiptFiles)
|
||||||
|
const form = row.form && typeof row.form === 'object' ? row.form : row
|
||||||
|
walk(form.files)
|
||||||
|
walk(form.attachments)
|
||||||
|
;(form.invoices || row.invoices || []).forEach((inv) => walk(inv && inv.files))
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
export function asList(raw) {
|
||||||
|
if (Array.isArray(raw)) return raw.filter((x) => x && typeof x === 'object')
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
const nested = raw.records || raw.list || raw.items || raw.messages || raw.projects || raw.data
|
||||||
|
if (Array.isArray(nested)) return nested.filter((x) => x && typeof x === 'object')
|
||||||
|
return [raw]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstText(row, keys, fallback = '') {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = row && row[k]
|
||||||
|
if (v != null && String(v).trim() && String(v) !== 'null') return String(v).trim()
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initial(name) {
|
||||||
|
const s = String(name || '').trim()
|
||||||
|
return s ? s.slice(0, 1) : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskPhone(phone) {
|
||||||
|
const s = String(phone || '')
|
||||||
|
if (s.length < 7) return s
|
||||||
|
return s.slice(0, 3) + '****' + s.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function asDate(t) {
|
||||||
|
if (t == null || t === '') return null
|
||||||
|
let d
|
||||||
|
const n = Number(t)
|
||||||
|
if (!Number.isNaN(n) && String(t).length >= 10) {
|
||||||
|
d = new Date(n < 1e12 ? n * 1000 : n)
|
||||||
|
} else {
|
||||||
|
d = new Date(t)
|
||||||
|
}
|
||||||
|
if (Number.isNaN(d.getTime())) return null
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayStart(d) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function period(d) {
|
||||||
|
const h = d.getHours()
|
||||||
|
if (h < 6) return '凌晨'
|
||||||
|
if (h < 12) return '上午'
|
||||||
|
if (h < 13) return '中午'
|
||||||
|
if (h < 18) return '下午'
|
||||||
|
return '晚上'
|
||||||
|
}
|
||||||
|
|
||||||
|
function hm12(d) {
|
||||||
|
const h = d.getHours() % 12 || 12
|
||||||
|
const m = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${h}:${m}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTime(t) {
|
||||||
|
const d = asDate(t)
|
||||||
|
if (!d) return t == null ? '' : String(t)
|
||||||
|
const label = period(d) + hm12(d)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = (dayStart(now) - dayStart(d)) / 86400000
|
||||||
|
if (diff === 0) return label
|
||||||
|
if (diff === 1) return '昨天 ' + label
|
||||||
|
if (diff < 7) {
|
||||||
|
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||||
|
return week[d.getDay()] + ' ' + label
|
||||||
|
}
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日 ${label}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtListTime(t) {
|
||||||
|
const d = asDate(t)
|
||||||
|
if (!d) return t == null ? '' : String(t)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = (dayStart(now) - dayStart(d)) / 86400000
|
||||||
|
if (diff === 0) return period(d) + hm12(d)
|
||||||
|
if (diff === 1) return '昨天'
|
||||||
|
if (diff < 7) {
|
||||||
|
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||||
|
return week[d.getDay()]
|
||||||
|
}
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pad2(n) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clockText(date) {
|
||||||
|
const d = date || new Date()
|
||||||
|
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yuan(v) {
|
||||||
|
const n = Number(v)
|
||||||
|
if (Number.isNaN(n)) return ''
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dmId(a, b) {
|
||||||
|
const pair = [String(a), String(b)].sort()
|
||||||
|
return `dm:${pair[0]}:${pair[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convType(s) {
|
||||||
|
const t = String((s && s.type) || '')
|
||||||
|
if (t === 'dm') return 'direct'
|
||||||
|
if (t === 'group' || t === 'work' || t === 'files' || t === 'direct') return t
|
||||||
|
const id = String((s && s.id) || '')
|
||||||
|
if (id.startsWith('g:')) return 'group'
|
||||||
|
if (id.startsWith('work:')) return 'work'
|
||||||
|
if (id.startsWith('files:')) return 'files'
|
||||||
|
return 'direct'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toast(title, icon = 'none') {
|
||||||
|
uni.showToast({ title: String(title || ''), icon, duration: 2200 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
const ImType = {
|
||||||
|
login: 0,
|
||||||
|
keepAlive: 1,
|
||||||
|
commonData: 2,
|
||||||
|
logout: 3,
|
||||||
|
recived: 4,
|
||||||
|
responseLogin: 50,
|
||||||
|
responseKeepAlive: 51,
|
||||||
|
kickout: 54
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImTypeu = {
|
||||||
|
text: 1,
|
||||||
|
image: 2,
|
||||||
|
file: 3,
|
||||||
|
card: 4,
|
||||||
|
recall: 5,
|
||||||
|
read: 6,
|
||||||
|
call: 8,
|
||||||
|
group: 9
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(partial) {
|
||||||
|
return {
|
||||||
|
bridge: false,
|
||||||
|
type: 0,
|
||||||
|
dataContent: null,
|
||||||
|
from: '-1',
|
||||||
|
to: '-1',
|
||||||
|
fp: null,
|
||||||
|
QoS: false,
|
||||||
|
typeu: -1,
|
||||||
|
sm: Date.now(),
|
||||||
|
...partial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuid() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||||
|
return v.toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImClient {
|
||||||
|
constructor() {
|
||||||
|
this.userId = ''
|
||||||
|
this.token = ''
|
||||||
|
this.url = ''
|
||||||
|
this._closed = true
|
||||||
|
this._tries = 0
|
||||||
|
this._alive = 0
|
||||||
|
this._reconnect = 0
|
||||||
|
this._handlers = {}
|
||||||
|
this._bound = false
|
||||||
|
this._opened = false
|
||||||
|
this._ready = false
|
||||||
|
this._queue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, fn) {
|
||||||
|
this._handlers[event] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, payload) {
|
||||||
|
const fn = this._handlers[event]
|
||||||
|
if (fn) fn(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
connect({ userId, token, url }) {
|
||||||
|
this._closed = false
|
||||||
|
this.userId = userId
|
||||||
|
this.token = token
|
||||||
|
this.url = url
|
||||||
|
this._open()
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindSocket() {
|
||||||
|
if (this._bound) return
|
||||||
|
this._bound = true
|
||||||
|
uni.onSocketOpen(() => {
|
||||||
|
this._tries = 0
|
||||||
|
this._opened = true
|
||||||
|
this._send(frame({
|
||||||
|
type: ImType.login,
|
||||||
|
from: String(this.userId || ''),
|
||||||
|
to: '0',
|
||||||
|
dataContent: JSON.stringify({
|
||||||
|
loginUserId: String(this.userId || ''),
|
||||||
|
loginToken: this.token,
|
||||||
|
extra: 'hbuilder-app',
|
||||||
|
firstLoginTime: 0
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
uni.onSocketMessage((res) => this._onMessage(res.data))
|
||||||
|
uni.onSocketClose(() => {
|
||||||
|
this._opened = false
|
||||||
|
this._ready = false
|
||||||
|
this._clearAlive()
|
||||||
|
if (!this._closed) this._schedule()
|
||||||
|
})
|
||||||
|
uni.onSocketError(() => {
|
||||||
|
if (!this._closed) this._schedule()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_open() {
|
||||||
|
this._clear()
|
||||||
|
this.emit('status', 'reconnecting')
|
||||||
|
this._bindSocket()
|
||||||
|
uni.connectSocket({ url: this.url })
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMessage(raw) {
|
||||||
|
let p
|
||||||
|
try {
|
||||||
|
p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||||
|
} catch (_) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!p || typeof p !== 'object') return
|
||||||
|
const type = p.type
|
||||||
|
if (type === ImType.responseLogin) {
|
||||||
|
let info = {}
|
||||||
|
try {
|
||||||
|
info = JSON.parse(p.dataContent || '{}')
|
||||||
|
} catch (_) {}
|
||||||
|
if (info.code === 0) {
|
||||||
|
this._ready = true
|
||||||
|
this.emit('status', 'connected')
|
||||||
|
this._keep()
|
||||||
|
this._flush()
|
||||||
|
} else {
|
||||||
|
this.emit('status', 'offline')
|
||||||
|
this.emit('loginFailed', info)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === ImType.responseKeepAlive) return
|
||||||
|
if (type === ImType.kickout) {
|
||||||
|
this._closed = true
|
||||||
|
this.emit('kick')
|
||||||
|
try { uni.closeSocket() } catch (_) {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === ImType.recived) {
|
||||||
|
this.emit('ack', p.fp || p.dataContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === ImType.commonData) {
|
||||||
|
if (p.QoS && p.fp) {
|
||||||
|
this._send(frame({
|
||||||
|
type: ImType.recived,
|
||||||
|
from: this.userId,
|
||||||
|
to: '0',
|
||||||
|
dataContent: p.fp,
|
||||||
|
fp: p.fp
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
let data = {}
|
||||||
|
try {
|
||||||
|
const dc = p.dataContent
|
||||||
|
data = typeof dc === 'string' ? JSON.parse(dc) : (dc || {})
|
||||||
|
} catch (_) {
|
||||||
|
data = { text: p.dataContent, kind: 'text' }
|
||||||
|
}
|
||||||
|
this.emit('data', { ...p, data, typeu: p.typeu || ImTypeu.text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendData({ to, typeu, payload, qos = true }) {
|
||||||
|
const fp = uuid()
|
||||||
|
this._send(frame({
|
||||||
|
type: ImType.commonData,
|
||||||
|
from: String(this.userId || ''),
|
||||||
|
to: String(to || ''),
|
||||||
|
typeu,
|
||||||
|
QoS: qos,
|
||||||
|
fp,
|
||||||
|
dataContent: JSON.stringify(payload)
|
||||||
|
}))
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this._closed = true
|
||||||
|
try {
|
||||||
|
this._send(frame({ type: ImType.logout, from: this.userId, to: '0' }))
|
||||||
|
} catch (_) {}
|
||||||
|
try { uni.closeSocket() } catch (_) {}
|
||||||
|
this._clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
_send(p) {
|
||||||
|
const raw = JSON.stringify(p)
|
||||||
|
const ctrl = p.type === ImType.login || p.type === ImType.keepAlive || p.type === ImType.logout || p.type === ImType.recived
|
||||||
|
if (!this._opened || (!this._ready && !ctrl)) {
|
||||||
|
if (!ctrl) this._queue.push(raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
uni.sendSocketMessage({
|
||||||
|
data: raw,
|
||||||
|
fail: () => {
|
||||||
|
if (!ctrl) this._queue.push(raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (_) {
|
||||||
|
if (!ctrl) this._queue.push(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_flush() {
|
||||||
|
const pending = this._queue.splice(0, this._queue.length)
|
||||||
|
pending.forEach((raw) => {
|
||||||
|
try { uni.sendSocketMessage({ data: raw }) } catch (_) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_keep() {
|
||||||
|
this._clearAlive()
|
||||||
|
this._alive = setInterval(() => {
|
||||||
|
this._send(frame({ type: ImType.keepAlive, from: this.userId, to: '0', dataContent: '{}' }))
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
_schedule() {
|
||||||
|
this.emit('status', 'reconnecting')
|
||||||
|
const wait = Math.min(15000, 800 * Math.pow(2, this._tries))
|
||||||
|
this._tries += 1
|
||||||
|
this._reconnect = setTimeout(() => this._open(), wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearAlive() {
|
||||||
|
if (this._alive) {
|
||||||
|
clearInterval(this._alive)
|
||||||
|
this._alive = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clear() {
|
||||||
|
this._clearAlive()
|
||||||
|
if (this._reconnect) {
|
||||||
|
clearTimeout(this._reconnect)
|
||||||
|
this._reconnect = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imClient = new ImClient()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Web 路由 → 手机页。工作台 shortcuts / pendingItems 下发的是 Web path。
|
||||||
|
*/
|
||||||
|
export function mobileUrl(webPath, extra = {}) {
|
||||||
|
const raw = String(webPath || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (raw.startsWith('/pages/')) return raw
|
||||||
|
const qIndex = raw.indexOf('?')
|
||||||
|
const path = qIndex >= 0 ? raw.slice(0, qIndex) : raw
|
||||||
|
const query = qIndex >= 0 ? raw.slice(qIndex + 1) : ''
|
||||||
|
const no = extra.no || extra.id || extra.requestNo || ''
|
||||||
|
|
||||||
|
if (path === '/apply') return '/pages/apply/list'
|
||||||
|
const apply = path.match(/^\/apply\/([^/]+)$/)
|
||||||
|
if (apply) return '/pages/apply/form?kind=' + encodeURIComponent(apply[1])
|
||||||
|
|
||||||
|
if (path.startsWith('/todo')) {
|
||||||
|
if (no) return '/pages/todo/detail?no=' + encodeURIComponent(no)
|
||||||
|
const tab = path.split('/')[2] || 'pending'
|
||||||
|
return '/pages/todo/list?tab=' + encodeURIComponent(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/contacts') return '/pages/tabs/contacts'
|
||||||
|
if (path === '/profile' || path === '/me') return '/pages/me/archive'
|
||||||
|
if (path.startsWith('/hr/notice') || path === '/notice') return '/pages/notice/list'
|
||||||
|
if (path.startsWith('/report')) return '/pages/reports/list'
|
||||||
|
if (path.includes('clock') || path.includes('attend')) return '/pages/tabs/clock'
|
||||||
|
if (path.startsWith('/work')) {
|
||||||
|
if (no) return '/pages/coop/detail?id=' + encodeURIComponent(no)
|
||||||
|
return '/pages/coop/list'
|
||||||
|
}
|
||||||
|
|
||||||
|
const erp = [
|
||||||
|
['/project', 'project'],
|
||||||
|
['/bidding', 'bidding'],
|
||||||
|
['/finance', 'finance'],
|
||||||
|
['/hr', 'hr'],
|
||||||
|
['/contracts', 'contracts'],
|
||||||
|
['/seals', 'seals'],
|
||||||
|
['/crm', 'crm'],
|
||||||
|
['/system', 'system']
|
||||||
|
]
|
||||||
|
for (const [prefix, id] of erp) {
|
||||||
|
if (path === prefix || path.startsWith(prefix + '/')) return '/pages/erp/hub?id=' + id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const mapped = mobileUrl(path, extra)
|
||||||
|
if (mapped && mapped.includes('?')) return mapped + '&' + query
|
||||||
|
if (mapped) return mapped + '?' + query
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openHref(webPath, extra = {}) {
|
||||||
|
const url = mobileUrl(webPath, extra)
|
||||||
|
if (!url) return false
|
||||||
|
if (url.startsWith('/pages/tabs/')) {
|
||||||
|
uni.switchTab({ url: url.split('?')[0] })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI = {
|
||||||
|
expense: '🧾', loan: '¥', leave: '休', trip: '✈', procurement: '购',
|
||||||
|
overtime: '加', outing: '出', card: '补', transfer: '转', regularize: '正',
|
||||||
|
resign: '离', layoff: '裁', seal: '章', receive: '领', clock: '📍',
|
||||||
|
todo: '☑', apply: '✎', report: '▤', salary: '💳', perf: '↗',
|
||||||
|
archive: '👤', notice: '📢', coop: '🤝', contacts: '☎',
|
||||||
|
bidding: '◎', project: '▣', finance: '◈', hr: '☰', contracts: '▤',
|
||||||
|
seals: '◆', crm: '◇', system: '⚙'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tileEmoji(item) {
|
||||||
|
if (item && item.emoji) return item.emoji
|
||||||
|
const to = String((item && (item.to || item.url || item.path)) || '')
|
||||||
|
const m = to.match(/\/apply\/([^/?]+)/)
|
||||||
|
if (m && EMOJI[m[1]]) return EMOJI[m[1]]
|
||||||
|
const id = String((item && (item.id || item.kind || item.key)) || '')
|
||||||
|
if (EMOJI[id]) return EMOJI[id]
|
||||||
|
const label = String((item && (item.label || item.title)) || '')
|
||||||
|
return label ? label.slice(0, 1) : '·'
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* 权限只认 /auth/my-perms。
|
||||||
|
* 和 Web permissions.js 一致:格子有 allowed 就用格子;超管看服务端下发的格子。
|
||||||
|
* 工作台入口可见性禁止写死人名/岗位。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function cellAllowed(cell) {
|
||||||
|
if (cell === true || cell === 1 || cell === '1') return true
|
||||||
|
if (cell === false || cell === 0 || cell === '0') return false
|
||||||
|
if (cell && typeof cell === 'object' && 'allowed' in cell) {
|
||||||
|
return cell.allowed === true || cell.allowed === 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMenu(menus, menuId, action = 'view', isSuper = false) {
|
||||||
|
if (!menuId) return false
|
||||||
|
const block = menus && typeof menus === 'object' ? menus[menuId] : null
|
||||||
|
const cell = block && typeof block === 'object' ? block[action] : undefined
|
||||||
|
if (cell !== undefined && cell !== null) return cellAllowed(cell)
|
||||||
|
return !!isSuper
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleOf(catalog, menus, isSuper) {
|
||||||
|
return (catalog || []).filter((item) => canMenu(menus, item.menu, item.action || 'view', isSuper))
|
||||||
|
}
|
||||||
@@ -0,0 +1,908 @@
|
|||||||
|
import { IM_WS, livekitPublicUrl, callRoomOf } from './config.js'
|
||||||
|
import * as api from './api.js'
|
||||||
|
import { imClient, ImTypeu } from './im.js'
|
||||||
|
import { canMenu, visibleOf } from './perms.js'
|
||||||
|
import { PERSONAL_APPS, ERP_MODULES } from './catalog.js'
|
||||||
|
import { asList, convType, dmId, fmtListTime } from './format.js'
|
||||||
|
|
||||||
|
const KEY_TOKEN = 'fysx.token'
|
||||||
|
const KEY_PHONE = 'fysx.phone'
|
||||||
|
const KEY_PWD = 'fysx.lastPwd'
|
||||||
|
|
||||||
|
function personFrom(raw, online = false) {
|
||||||
|
const name = String((raw && (raw.realName || raw.name || raw.username)) || '同事')
|
||||||
|
const photoId = (raw && (raw.idPhotoFileId || raw.idPhoto)) || ''
|
||||||
|
return {
|
||||||
|
id: String((raw && raw.id) || ''),
|
||||||
|
name,
|
||||||
|
username: String((raw && (raw.username || raw.phone)) || ''),
|
||||||
|
avatar: api.photoUrl(raw),
|
||||||
|
idPhotoFileId: photoId ? String(photoId) : '',
|
||||||
|
title: String((raw && (raw.role || raw.title)) || '员工'),
|
||||||
|
department: String((raw && (raw.dept || raw.department)) || ''),
|
||||||
|
departmentId: String((raw && (raw.deptId || raw.departmentId)) || ''),
|
||||||
|
phone: String((raw && (raw.phone || raw.username)) || ''),
|
||||||
|
employeeStatus: String((raw && (raw.employeeStatus || raw.statusLabel)) || ''),
|
||||||
|
jobId: String((raw && (raw.jobNo || raw.jobId || raw.empNo || raw.employeeNo)) || ''),
|
||||||
|
isSuper: !!(raw && (raw.isSuper === true || raw.superAdmin === true)),
|
||||||
|
online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindFromTypeu(typeu, fallback = 'text') {
|
||||||
|
if (typeu === ImTypeu.image) return 'image'
|
||||||
|
if (typeu === ImTypeu.file) return 'file'
|
||||||
|
if (typeu === ImTypeu.card) return 'card'
|
||||||
|
if (typeu === ImTypeu.recall) return 'recall'
|
||||||
|
return fallback || 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewOf(msg) {
|
||||||
|
if (!msg) return ''
|
||||||
|
if (msg.recalled) return '[撤回]'
|
||||||
|
if (msg.kind === 'image') return '[图片]'
|
||||||
|
if (msg.kind === 'voice') return msg.text || '[语音]'
|
||||||
|
if (msg.kind === 'file') return `[文件] ${msg.fileName || ''}`.trim()
|
||||||
|
if (msg.kind === 'card') return msg.title || msg.text || '[卡片]'
|
||||||
|
return String(msg.text || '[消息]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMsg(raw, meId, typeu) {
|
||||||
|
const p = raw && raw.payload && typeof raw.payload === 'object' ? raw.payload : {}
|
||||||
|
const from = String((raw && (raw.from || raw.fromId || raw.senderId)) || p.from || '')
|
||||||
|
const kind = kindFromTypeu(typeu, String((raw && (raw.kind || raw.type)) || p.kind || 'text'))
|
||||||
|
const recalled = kind === 'recall' || !!(raw && raw.recalled)
|
||||||
|
const text = String((raw && (raw.text || raw.content || raw.body)) || p.text || p.content || '')
|
||||||
|
const name = String((raw && (raw.name || raw.fileName)) || p.name || '')
|
||||||
|
const src = (raw && (raw.src || raw.url || raw.fileUrl)) || p.src || p.url || ''
|
||||||
|
const fid = (raw && (raw.fileId || raw.file_id)) || p.fileId || ''
|
||||||
|
return {
|
||||||
|
id: String((raw && (raw.id || raw.fp || raw.msgId)) || ''),
|
||||||
|
fromId: from,
|
||||||
|
text: recalled ? (from === meId ? '你撤回了一条消息' : '对方撤回了一条消息') : text,
|
||||||
|
kind: recalled ? 'text' : kind,
|
||||||
|
time: Number((raw && (raw.time || raw.sm || raw.createdAt)) || Date.now()) || Date.now(),
|
||||||
|
mine: from === meId,
|
||||||
|
recalled,
|
||||||
|
fileName: name,
|
||||||
|
fileUrl: api.absUrl(src),
|
||||||
|
fileId: String(fid || ''),
|
||||||
|
title: String((raw && (raw.title || raw.summary)) || p.title || ''),
|
||||||
|
no: String((raw && (raw.no || raw.requestNo)) || p.no || ''),
|
||||||
|
taskId: String((raw && (raw.taskId || raw.workId)) || p.taskId || ''),
|
||||||
|
href: String((raw && raw.href) || p.href || ''),
|
||||||
|
duration: Number((raw && raw.duration) || p.duration) || 0,
|
||||||
|
quote: p.quote || raw.quote || null,
|
||||||
|
read: !!(raw && (raw.read === true || raw.readAt || raw.isRead)),
|
||||||
|
readReceipt: (raw && raw.readReceipt) || p.readReceipt || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConv(s, me, people) {
|
||||||
|
const type = convType(s)
|
||||||
|
const memberIds = ((s.memberIds || s.members || [])).map((e) => String(e))
|
||||||
|
let name = String(s.title || s.name || '会话')
|
||||||
|
let avatar = api.absUrl(s.avatar || '')
|
||||||
|
const others = memberIds.filter((id) => id !== (me && me.id))
|
||||||
|
let peer = String(s.peerId || (others[0] || ''))
|
||||||
|
if (!peer || peer === (me && me.id)) {
|
||||||
|
const sid = String(s.id || '')
|
||||||
|
const dm = sid.match(/^dm:([^:]+):(.+)$/)
|
||||||
|
if (dm) peer = dm[1] === String(me && me.id) ? dm[2] : dm[1]
|
||||||
|
}
|
||||||
|
if (type === 'direct' && people[peer]) {
|
||||||
|
name = people[peer].name
|
||||||
|
avatar = people[peer].avatar
|
||||||
|
} else if (type === 'work') {
|
||||||
|
name = '工作通知'
|
||||||
|
} else if (type === 'files') {
|
||||||
|
name = '文件传输助手'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
avatar,
|
||||||
|
peerId: peer,
|
||||||
|
memberIds,
|
||||||
|
lastMessage: String(s.preview || s.lastMessage || ''),
|
||||||
|
lastTime: fmtListTime(s.time || s.updatedAt),
|
||||||
|
unread: Number(s.unread || s.unreadCount || 0) || 0,
|
||||||
|
pinned: s.pinned === true || s.isPinned === true,
|
||||||
|
muted: s.muted === true || s.isMuted === true,
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = {
|
||||||
|
phase: 'boot',
|
||||||
|
token: '',
|
||||||
|
lastPassword: '',
|
||||||
|
me: null,
|
||||||
|
people: {},
|
||||||
|
depts: [],
|
||||||
|
conversations: [],
|
||||||
|
menus: {},
|
||||||
|
overview: {},
|
||||||
|
announcements: [],
|
||||||
|
shortcuts: [],
|
||||||
|
pendingItems: [],
|
||||||
|
todos: [],
|
||||||
|
attendance: [],
|
||||||
|
attendRules: { workStart: '09:00', workEnd: '18:00', locations: [] },
|
||||||
|
todoCount: 0,
|
||||||
|
liveStatus: 'offline',
|
||||||
|
call: null,
|
||||||
|
pendingForward: null,
|
||||||
|
activeSid: '',
|
||||||
|
_photoAt: 0,
|
||||||
|
smsRequired: false,
|
||||||
|
smsPaused: false,
|
||||||
|
fixedSms: '',
|
||||||
|
error: '',
|
||||||
|
busy: false,
|
||||||
|
_imBound: false,
|
||||||
|
listeners: [],
|
||||||
|
|
||||||
|
on(fn) {
|
||||||
|
const wrap = () => {
|
||||||
|
try { fn() } catch (_) {}
|
||||||
|
}
|
||||||
|
this.listeners.push(wrap)
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter((x) => x !== wrap)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
this.listeners.slice().forEach((fn) => {
|
||||||
|
try { fn() } catch (_) {}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
can(menuId, action = 'view') {
|
||||||
|
return canMenu(this.menus, menuId, action, !!(this.me && this.me.isSuper))
|
||||||
|
},
|
||||||
|
|
||||||
|
personalApps() {
|
||||||
|
return visibleOf(PERSONAL_APPS, this.menus, !!(this.me && this.me.isSuper))
|
||||||
|
},
|
||||||
|
|
||||||
|
erpModules() {
|
||||||
|
return visibleOf(ERP_MODULES, this.menus, !!(this.me && this.me.isSuper))
|
||||||
|
},
|
||||||
|
|
||||||
|
person(id) {
|
||||||
|
if (this.people[id]) return this.people[id]
|
||||||
|
return Object.values(this.people).find((p) => p.username === id || p.phone === id) || null
|
||||||
|
},
|
||||||
|
|
||||||
|
findConv(id) {
|
||||||
|
return this.conversations.find((c) => c.id === id) || null
|
||||||
|
},
|
||||||
|
|
||||||
|
unreadTotal() {
|
||||||
|
return this.conversations.reduce((s, c) => s + (c.unread || 0), 0)
|
||||||
|
},
|
||||||
|
|
||||||
|
async boot() {
|
||||||
|
try {
|
||||||
|
const opt = await api.loginOptions()
|
||||||
|
this.smsRequired = opt && opt.smsLogin === true
|
||||||
|
this.smsPaused = opt && opt.notifyPaused === true
|
||||||
|
if (opt && opt.fixedLoginCode != null) this.fixedSms = String(opt.fixedLoginCode)
|
||||||
|
} catch (_) {
|
||||||
|
this.smsRequired = false
|
||||||
|
}
|
||||||
|
const saved = uni.getStorageSync(KEY_TOKEN)
|
||||||
|
if (!saved) {
|
||||||
|
this.phase = 'login'
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.enter(saved)
|
||||||
|
} catch (_) {
|
||||||
|
uni.removeStorageSync(KEY_TOKEN)
|
||||||
|
this.phase = 'login'
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async login({ phone, password, sms }) {
|
||||||
|
this.busy = true
|
||||||
|
this.error = ''
|
||||||
|
this.emit()
|
||||||
|
try {
|
||||||
|
const data = await api.login({
|
||||||
|
username: String(phone || '').trim(),
|
||||||
|
password,
|
||||||
|
...(String(sms || '').trim() ? { smsCode: String(sms).trim() } : {})
|
||||||
|
})
|
||||||
|
const tk = String((data && data.token) || '')
|
||||||
|
const user = (data && data.user) || {}
|
||||||
|
if (!tk) throw new Error('登录未返回凭证')
|
||||||
|
this.lastPassword = password
|
||||||
|
uni.setStorageSync(KEY_TOKEN, tk)
|
||||||
|
uni.setStorageSync(KEY_PHONE, String(phone || '').trim())
|
||||||
|
uni.setStorageSync(KEY_PWD, password)
|
||||||
|
if (user.forcePasswordChange === true) {
|
||||||
|
this.token = tk
|
||||||
|
this.me = personFrom(user)
|
||||||
|
this.phase = 'forcePassword'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.enter(tk)
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || String(e)
|
||||||
|
this.phase = 'login'
|
||||||
|
} finally {
|
||||||
|
this.busy = false
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async changePassword(next, confirm) {
|
||||||
|
this.busy = true
|
||||||
|
this.error = ''
|
||||||
|
this.emit()
|
||||||
|
try {
|
||||||
|
await api.changePassword({
|
||||||
|
oldPassword: this.lastPassword || uni.getStorageSync(KEY_PWD),
|
||||||
|
newPassword: next,
|
||||||
|
confirmPassword: confirm
|
||||||
|
}, this.token)
|
||||||
|
await this.enter(this.token)
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || String(e)
|
||||||
|
} finally {
|
||||||
|
this.busy = false
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async enter(tk) {
|
||||||
|
this.token = tk
|
||||||
|
const boot = await api.bootstrap(tk)
|
||||||
|
this.me = personFrom(boot.me || {})
|
||||||
|
if (!this.me.id && boot.me) this.me.id = String(boot.me.userId || boot.me.username || '')
|
||||||
|
const online = new Set((boot.online || []).map((e) => String(e)))
|
||||||
|
this.people = {}
|
||||||
|
;(boot.people || []).forEach((row) => {
|
||||||
|
if (!row) return
|
||||||
|
const p = personFrom(row, online.has(String(row.id)))
|
||||||
|
this.people[p.id] = p
|
||||||
|
})
|
||||||
|
this.depts = boot.depts || []
|
||||||
|
this.todoCount = Number(boot.todoCount || 0) || 0
|
||||||
|
this.conversations = (boot.sessions || []).map((s) => toConv(s, this.me, this.people))
|
||||||
|
this._sort()
|
||||||
|
if (!this._imBound) {
|
||||||
|
this._imBound = true
|
||||||
|
imClient.on('status', (s) => {
|
||||||
|
this.liveStatus = String(s)
|
||||||
|
this.emit()
|
||||||
|
})
|
||||||
|
imClient.on('kick', () => this.logout())
|
||||||
|
imClient.on('data', (pack) => this._onPacket(pack))
|
||||||
|
}
|
||||||
|
imClient.connect({ userId: this.me.id, token: tk, url: IM_WS })
|
||||||
|
await this.refreshPerms()
|
||||||
|
this.phase = 'app'
|
||||||
|
this.refreshPeoplePhotos()
|
||||||
|
this.refreshWorkbench()
|
||||||
|
this.refreshAttendance()
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshPerms() {
|
||||||
|
try {
|
||||||
|
const raw = await api.oaGet('/auth/my-perms', this.token)
|
||||||
|
this.menus = (raw && raw.menus) || {}
|
||||||
|
} catch (_) {
|
||||||
|
this.menus = {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IM bootstrap 的 people 通常没有证件照票。
|
||||||
|
* 证件照在 OA:GET /api/org/users → avatar=/api/org/photo/{id}?ticket=
|
||||||
|
*/
|
||||||
|
async refreshPeoplePhotos() {
|
||||||
|
if (this._photoAt && Date.now() - this._photoAt < 60000) return
|
||||||
|
this._photoAt = Date.now()
|
||||||
|
try {
|
||||||
|
const rows = asList(await api.oaGet('/org/users', this.token))
|
||||||
|
const online = new Set(
|
||||||
|
Object.values(this.people).filter((p) => p.online).map((p) => p.id)
|
||||||
|
)
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (!row) return
|
||||||
|
const next = personFrom(row, online.has(String(row.id)))
|
||||||
|
if (!next.id) return
|
||||||
|
const prev = this.people[next.id]
|
||||||
|
this.people[next.id] = prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
...next,
|
||||||
|
online: prev.online || next.online,
|
||||||
|
avatar: next.avatar || prev.avatar
|
||||||
|
}
|
||||||
|
: next
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const meRaw = await api.oaGet('/auth/me', this.token)
|
||||||
|
if (meRaw && typeof meRaw === 'object') {
|
||||||
|
const mine = personFrom(meRaw, true)
|
||||||
|
if (this.me) {
|
||||||
|
this.me = {
|
||||||
|
...this.me,
|
||||||
|
...mine,
|
||||||
|
id: this.me.id || mine.id,
|
||||||
|
avatar: mine.avatar || this.me.avatar
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.me = mine
|
||||||
|
}
|
||||||
|
if (this.me.id) {
|
||||||
|
this.people[this.me.id] = {
|
||||||
|
...(this.people[this.me.id] || {}),
|
||||||
|
...this.me,
|
||||||
|
online: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
this.conversations.forEach((c) => {
|
||||||
|
if (c.type !== 'direct' || !c.peerId) return
|
||||||
|
const p = this.people[c.peerId]
|
||||||
|
if (!p) return
|
||||||
|
if (p.avatar) c.avatar = p.avatar
|
||||||
|
if (p.name) c.name = p.name
|
||||||
|
})
|
||||||
|
this.emit()
|
||||||
|
} catch (_) {
|
||||||
|
this._photoAt = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_sort() {
|
||||||
|
this.conversations.sort((a, b) => {
|
||||||
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||||
|
return String(b.lastTime).localeCompare(String(a.lastTime))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
_onPacket(pack) {
|
||||||
|
if (!pack) return
|
||||||
|
const typeu = pack.typeu
|
||||||
|
const data = pack.data && typeof pack.data === 'object' ? pack.data : {}
|
||||||
|
if (typeu === ImTypeu.recall) {
|
||||||
|
const conv = this.findConv(String(data.sessionId || ''))
|
||||||
|
if (!conv) return
|
||||||
|
conv.messages.forEach((m) => {
|
||||||
|
if (m.id === String(data.msgId || '')) {
|
||||||
|
m.recalled = true
|
||||||
|
m.text = '对方撤回了一条消息'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeu === ImTypeu.call) {
|
||||||
|
this._onCallSignal(String(pack.from || data.from || ''), data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeu === ImTypeu.read) {
|
||||||
|
const sid = String(data.sessionId || pack.to || '')
|
||||||
|
const conv = this.findConv(sid) || this.findConv(this.dmIdOf(pack.from))
|
||||||
|
if (conv) {
|
||||||
|
;(conv.messages || []).forEach((m) => {
|
||||||
|
if (m.mine && !m.recalled) m.read = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeu === ImTypeu.group) {
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sid = String(data.sessionId || pack.to || '')
|
||||||
|
let conv = this.findConv(sid)
|
||||||
|
if (!conv && sid) {
|
||||||
|
conv = { id: sid, type: sid.startsWith('g:') ? 'group' : 'direct', name: '会话', messages: [], unread: 0, lastMessage: '', lastTime: '', pinned: false, muted: false, avatar: '', peerId: '', memberIds: [] }
|
||||||
|
this.conversations.unshift(conv)
|
||||||
|
}
|
||||||
|
if (!conv) return
|
||||||
|
const id = String(pack.fp || data.msgId || Date.now())
|
||||||
|
if (conv.messages.some((m) => m.id && m.id === id)) {
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const msg = mapMsg({ ...data, from: pack.from || data.from, id, fp: pack.fp, time: pack.sm || Date.now() }, this.me && this.me.id, pack.typeu)
|
||||||
|
if (msg.mine) {
|
||||||
|
const dup = conv.messages.find((m) => m.mine && !m.recalled && m.kind === msg.kind && m.text === msg.text && Math.abs(Number(m.time) - msg.time) < 4000)
|
||||||
|
if (dup) {
|
||||||
|
if (!dup.id) dup.id = msg.id
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conv.messages.push(msg)
|
||||||
|
conv.lastMessage = previewOf(msg)
|
||||||
|
conv.lastTime = fmtListTime(msg.time)
|
||||||
|
const viewing = this.activeSid && this.activeSid === conv.id
|
||||||
|
if (!msg.mine) {
|
||||||
|
if (viewing) {
|
||||||
|
conv.unread = 0
|
||||||
|
this.sendRead(conv)
|
||||||
|
} else {
|
||||||
|
conv.unread += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._sort()
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
dmIdOf(peerId) {
|
||||||
|
if (!this.me || !peerId) return ''
|
||||||
|
return dmId(this.me.id, peerId)
|
||||||
|
},
|
||||||
|
|
||||||
|
sendRead(conv) {
|
||||||
|
if (!conv || conv.type !== 'direct' || !this.me) return
|
||||||
|
const peer = this.sendTarget(conv)
|
||||||
|
if (!peer || peer === this.me.id) return
|
||||||
|
imClient.sendData({
|
||||||
|
to: peer,
|
||||||
|
typeu: ImTypeu.read,
|
||||||
|
qos: false,
|
||||||
|
payload: { sessionId: conv.id }
|
||||||
|
})
|
||||||
|
try { api.markRead(conv.id, this.token) } catch (_) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveChat(sid) {
|
||||||
|
this.activeSid = sid ? String(sid) : ''
|
||||||
|
},
|
||||||
|
|
||||||
|
async openChat(conv) {
|
||||||
|
if (!conv) return
|
||||||
|
this.activeSid = conv.id
|
||||||
|
try {
|
||||||
|
const rows = await api.messages(conv.id, this.token)
|
||||||
|
const incoming = asList(rows && rows.messages ? rows.messages : rows).map((m) => mapMsg(m, this.me && this.me.id, m.typeu))
|
||||||
|
const seen = new Set(incoming.map((m) => m.id).filter(Boolean))
|
||||||
|
const extras = (conv.messages || []).filter((m) => m.mine && m.id && !seen.has(m.id) && Date.now() - Number(m.time) < 120000)
|
||||||
|
conv.messages = incoming.concat(extras)
|
||||||
|
conv.unread = 0
|
||||||
|
await api.markRead(conv.id, this.token)
|
||||||
|
if (conv.type === 'direct') this.sendRead(conv)
|
||||||
|
} catch (_) {}
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
openDirect(peer) {
|
||||||
|
const id = dmId(this.me.id, peer.id)
|
||||||
|
let conv = this.findConv(id)
|
||||||
|
if (!conv) {
|
||||||
|
conv = { id, type: 'direct', name: peer.name, avatar: peer.avatar, peerId: peer.id, messages: [], unread: 0, lastMessage: '', lastTime: '', pinned: false, muted: false, memberIds: [this.me.id, peer.id] }
|
||||||
|
this.conversations.unshift(conv)
|
||||||
|
}
|
||||||
|
return conv
|
||||||
|
},
|
||||||
|
|
||||||
|
sendTarget(conv) {
|
||||||
|
if (!conv) return ''
|
||||||
|
if (conv.type === 'group' || conv.type === 'work' || conv.type === 'files') return String(conv.id)
|
||||||
|
const me = this.me ? String(this.me.id) : ''
|
||||||
|
let peer = String(conv.peerId || '')
|
||||||
|
if (!peer || peer === me || peer.startsWith('dm:') || peer.startsWith('g:')) {
|
||||||
|
const id = String(conv.id || '')
|
||||||
|
const m = id.match(/^dm:([^:]+):(.+)$/)
|
||||||
|
if (m) peer = m[1] === me ? m[2] : m[1]
|
||||||
|
}
|
||||||
|
if ((!peer || peer === me) && conv.memberIds && conv.memberIds.length) {
|
||||||
|
peer = conv.memberIds.map(String).find((id) => id && id !== me) || peer
|
||||||
|
}
|
||||||
|
return String(peer || conv.id || '')
|
||||||
|
},
|
||||||
|
|
||||||
|
sendText(conv, text, extra) {
|
||||||
|
const trimmed = String(text || '').trim()
|
||||||
|
if (!trimmed || !this.me) return
|
||||||
|
const quote = extra && extra.quote ? {
|
||||||
|
msgId: extra.quote.id || extra.quote.msgId || '',
|
||||||
|
fromId: extra.quote.fromId || '',
|
||||||
|
name: extra.quote.name || '',
|
||||||
|
text: extra.quote.text || previewOf(extra.quote)
|
||||||
|
} : null
|
||||||
|
const to = this.sendTarget(conv)
|
||||||
|
const fp = imClient.sendData({
|
||||||
|
to,
|
||||||
|
typeu: ImTypeu.text,
|
||||||
|
payload: { sessionId: conv.id, kind: 'text', text: trimmed, from: this.me.id, quote }
|
||||||
|
})
|
||||||
|
conv.messages.push({ id: fp, fromId: this.me.id, text: trimmed, kind: 'text', time: Date.now(), mine: true, recalled: false, quote, read: false })
|
||||||
|
conv.lastMessage = trimmed
|
||||||
|
conv.lastTime = fmtListTime(Date.now())
|
||||||
|
this._sort()
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMedia(conv, file) {
|
||||||
|
if (!conv || !this.me || !file) return
|
||||||
|
const name = String(file.name || file.fileName || '附件')
|
||||||
|
const type = String(file.contentType || '')
|
||||||
|
const isImg = type.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|heic)$/i.test(name)
|
||||||
|
const kind = isImg ? 'image' : 'file'
|
||||||
|
const src = file.url || (file.id ? `/api/files/${file.id}` : '')
|
||||||
|
const fp = imClient.sendData({
|
||||||
|
to: this.sendTarget(conv),
|
||||||
|
typeu: isImg ? ImTypeu.image : ImTypeu.file,
|
||||||
|
payload: { sessionId: conv.id, kind, text: isImg ? '[图片]' : name, src, name, fileId: file.id || '', from: this.me.id }
|
||||||
|
})
|
||||||
|
conv.messages.push({
|
||||||
|
id: fp,
|
||||||
|
fromId: this.me.id,
|
||||||
|
text: isImg ? '[图片]' : name,
|
||||||
|
kind,
|
||||||
|
time: Date.now(),
|
||||||
|
mine: true,
|
||||||
|
recalled: false,
|
||||||
|
read: false,
|
||||||
|
fileName: name,
|
||||||
|
fileUrl: file.url || api.absUrl(src),
|
||||||
|
fileId: String(file.id || '')
|
||||||
|
})
|
||||||
|
conv.lastMessage = isImg ? '[图片]' : `[文件] ${name}`
|
||||||
|
conv.lastTime = fmtListTime(Date.now())
|
||||||
|
this._sort()
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
sendVoice(conv, file) {
|
||||||
|
if (!conv || !this.me || !file) return
|
||||||
|
const sec = Math.max(1, Number(file.duration) || 1)
|
||||||
|
const src = file.url || (file.id ? `/api/files/${file.id}` : '')
|
||||||
|
const fp = imClient.sendData({
|
||||||
|
to: this.sendTarget(conv),
|
||||||
|
typeu: ImTypeu.file,
|
||||||
|
payload: {
|
||||||
|
sessionId: conv.id,
|
||||||
|
kind: 'voice',
|
||||||
|
text: `[语音] ${sec}″`,
|
||||||
|
src,
|
||||||
|
name: file.name || 'voice.mp3',
|
||||||
|
fileId: file.id || '',
|
||||||
|
duration: sec,
|
||||||
|
from: this.me.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
conv.messages.push({
|
||||||
|
id: fp,
|
||||||
|
fromId: this.me.id,
|
||||||
|
text: `[语音] ${sec}″`,
|
||||||
|
kind: 'voice',
|
||||||
|
time: Date.now(),
|
||||||
|
mine: true,
|
||||||
|
recalled: false,
|
||||||
|
read: false,
|
||||||
|
fileName: file.name || 'voice.mp3',
|
||||||
|
fileUrl: file.url || api.absUrl(src),
|
||||||
|
fileId: String(file.id || ''),
|
||||||
|
duration: sec
|
||||||
|
})
|
||||||
|
conv.lastMessage = `[语音] ${sec}″`
|
||||||
|
conv.lastTime = fmtListTime(Date.now())
|
||||||
|
this._sort()
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
_openCallPage() {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const cur = pages[pages.length - 1]
|
||||||
|
const route = cur && (cur.route || cur.$page && cur.$page.fullPath) || ''
|
||||||
|
if (String(route).indexOf('pages/chat/call') >= 0) {
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: '/pages/chat/call' })
|
||||||
|
},
|
||||||
|
|
||||||
|
_onCallSignal(from, data) {
|
||||||
|
const action = String((data && data.action) || '')
|
||||||
|
if (action === 'invite') {
|
||||||
|
this.call = {
|
||||||
|
direction: 'in',
|
||||||
|
peerId: String(from),
|
||||||
|
room: data.room || '',
|
||||||
|
url: livekitPublicUrl(data.url),
|
||||||
|
token: '',
|
||||||
|
video: data.video !== false,
|
||||||
|
status: 'ringing'
|
||||||
|
}
|
||||||
|
this._openCallPage()
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'accept' && this.call) {
|
||||||
|
this.call.status = 'active'
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'reject' || action === 'hangup') {
|
||||||
|
this.call = null
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCall(peerId, { video = true } = {}) {
|
||||||
|
const pid = String(peerId || '')
|
||||||
|
if (!pid || !this.me) throw new Error('无法发起通话')
|
||||||
|
const room = callRoomOf(this.me.id, pid)
|
||||||
|
const tk = await api.livekitToken({ peerId: pid, room }, this.token)
|
||||||
|
this.call = {
|
||||||
|
direction: 'out',
|
||||||
|
peerId: pid,
|
||||||
|
room: (tk && tk.room) || room,
|
||||||
|
url: livekitPublicUrl((tk && tk.url) || ''),
|
||||||
|
token: (tk && tk.token) || '',
|
||||||
|
video,
|
||||||
|
status: 'calling'
|
||||||
|
}
|
||||||
|
imClient.sendData({
|
||||||
|
to: pid,
|
||||||
|
typeu: ImTypeu.call,
|
||||||
|
payload: { action: 'invite', room: this.call.room, url: this.call.url, video }
|
||||||
|
})
|
||||||
|
this._openCallPage()
|
||||||
|
this.emit()
|
||||||
|
return this.call
|
||||||
|
},
|
||||||
|
|
||||||
|
async acceptCall() {
|
||||||
|
if (!this.call || !this.me) return
|
||||||
|
const tk = await api.livekitToken({ peerId: this.call.peerId, room: this.call.room }, this.token)
|
||||||
|
this.call.url = livekitPublicUrl((tk && tk.url) || this.call.url)
|
||||||
|
this.call.token = (tk && tk.token) || ''
|
||||||
|
this.call.status = 'active'
|
||||||
|
imClient.sendData({
|
||||||
|
to: this.call.peerId,
|
||||||
|
typeu: ImTypeu.call,
|
||||||
|
payload: { action: 'accept', room: this.call.room }
|
||||||
|
})
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
endCall(notify = true) {
|
||||||
|
const c = this.call
|
||||||
|
this.call = null
|
||||||
|
if (notify && c && c.peerId) {
|
||||||
|
imClient.sendData({
|
||||||
|
to: c.peerId,
|
||||||
|
typeu: ImTypeu.call,
|
||||||
|
qos: false,
|
||||||
|
payload: { action: 'hangup', room: c.room }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
recall(conv, msg) {
|
||||||
|
if (!conv || !msg || !msg.mine || msg.recalled) return
|
||||||
|
imClient.sendData({
|
||||||
|
to: this.sendTarget(conv),
|
||||||
|
typeu: ImTypeu.recall,
|
||||||
|
qos: false,
|
||||||
|
payload: { sessionId: conv.id, kind: 'recall', msgId: msg.id }
|
||||||
|
})
|
||||||
|
try { api.recall({ sessionId: conv.id, msgId: msg.id }, this.token) } catch (_) {}
|
||||||
|
msg.recalled = true
|
||||||
|
msg.text = '你撤回了一条消息'
|
||||||
|
conv.lastMessage = previewOf(msg)
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMessage(conv, msg) {
|
||||||
|
if (!conv || !msg) return
|
||||||
|
conv.messages = (conv.messages || []).filter((m) => m.id !== msg.id)
|
||||||
|
const last = conv.messages[conv.messages.length - 1]
|
||||||
|
conv.lastMessage = last ? previewOf(last) : ''
|
||||||
|
conv.lastTime = last ? fmtListTime(last.time) : conv.lastTime
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
forwardTo(dest, msg) {
|
||||||
|
if (!dest || !msg || msg.recalled) return
|
||||||
|
if (msg.kind === 'image' || msg.kind === 'file') {
|
||||||
|
this.sendMedia(dest, {
|
||||||
|
id: msg.fileId,
|
||||||
|
name: msg.fileName || (msg.kind === 'image' ? '图片' : '文件'),
|
||||||
|
url: msg.fileUrl,
|
||||||
|
contentType: msg.kind === 'image' ? 'image/jpeg' : ''
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.kind === 'voice') {
|
||||||
|
this.sendVoice(dest, {
|
||||||
|
id: msg.fileId,
|
||||||
|
name: msg.fileName || 'voice.mp3',
|
||||||
|
url: msg.fileUrl,
|
||||||
|
duration: msg.duration
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendText(dest, msg.text || msg.title || '')
|
||||||
|
},
|
||||||
|
|
||||||
|
forwardMany(dest, msgs, merge) {
|
||||||
|
const rows = (msgs || []).filter((m) => m && !m.recalled)
|
||||||
|
if (!dest || !rows.length) return
|
||||||
|
if (merge) {
|
||||||
|
this.sendText(dest, rows.map((m) => previewOf(m)).join('\n'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows.forEach((m) => this.forwardTo(dest, m))
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHistory(conv) {
|
||||||
|
if (!conv) return
|
||||||
|
conv.messages = []
|
||||||
|
conv.lastMessage = ''
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
hideConv(conv) {
|
||||||
|
if (!conv) return
|
||||||
|
this.conversations = this.conversations.filter((c) => c.id !== conv.id)
|
||||||
|
try { api.sessionAction({ sessionId: conv.id, hidden: true }, this.token) } catch (_) {}
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
markUnread(conv) {
|
||||||
|
if (!conv) return
|
||||||
|
conv.unread = Math.max(1, Number(conv.unread) || 1)
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
async setSession(conv, patch) {
|
||||||
|
if (!conv || !patch) return
|
||||||
|
if (patch.pinned != null) conv.pinned = !!patch.pinned
|
||||||
|
if (patch.muted != null) conv.muted = !!patch.muted
|
||||||
|
try {
|
||||||
|
await api.sessionAction({ sessionId: conv.id, ...patch }, this.token)
|
||||||
|
} catch (_) {}
|
||||||
|
this._sort()
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
async createGroup(name, memberIds) {
|
||||||
|
const ids = [...new Set([this.me && this.me.id, ...(memberIds || []).map(String)])].filter(Boolean)
|
||||||
|
const title = String(name || '').trim() || '群聊'
|
||||||
|
let sid = ''
|
||||||
|
try {
|
||||||
|
const data = await api.createGroup({ name: title, memberIds: ids }, this.token)
|
||||||
|
sid = String((data && (data.id || data.sessionId)) || '')
|
||||||
|
} catch (_) {}
|
||||||
|
if (!sid) sid = 'g:' + Date.now()
|
||||||
|
let conv = this.findConv(sid)
|
||||||
|
if (!conv) {
|
||||||
|
conv = {
|
||||||
|
id: sid,
|
||||||
|
type: 'group',
|
||||||
|
name: title,
|
||||||
|
avatar: '',
|
||||||
|
peerId: '',
|
||||||
|
memberIds: ids,
|
||||||
|
messages: [],
|
||||||
|
unread: 0,
|
||||||
|
lastMessage: '你创建了群聊',
|
||||||
|
lastTime: fmtListTime(Date.now()),
|
||||||
|
pinned: false,
|
||||||
|
muted: false
|
||||||
|
}
|
||||||
|
this.conversations.unshift(conv)
|
||||||
|
} else {
|
||||||
|
conv.name = title
|
||||||
|
conv.memberIds = ids
|
||||||
|
}
|
||||||
|
this._sort()
|
||||||
|
this.emit()
|
||||||
|
return conv
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshWorkbench() {
|
||||||
|
try {
|
||||||
|
const ov = await api.oaGet('/dashboard/overview', this.token)
|
||||||
|
this.overview = ov && typeof ov === 'object' ? ov : {}
|
||||||
|
this.pendingItems = asList(this.overview.pendingItems)
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
const sc = await api.oaGet('/dashboard/shortcuts', this.token)
|
||||||
|
this.shortcuts = asList(sc && sc.shortcuts ? sc.shortcuts : sc)
|
||||||
|
} catch (_) {
|
||||||
|
this.shortcuts = []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.announcements = asList(await api.oaGet('/announcements', this.token))
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
const page = await api.oaGet('/workflow/requests?tab=pending&page=1&size=30', this.token)
|
||||||
|
this.todos = asList(page && page.records ? page.records : page)
|
||||||
|
const all = Number(this.overview && this.overview.pendingAll)
|
||||||
|
this.todoCount = Number.isFinite(all) && all > 0 ? all : this.todos.length
|
||||||
|
} catch (_) {}
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshAttendance() {
|
||||||
|
try {
|
||||||
|
this.attendRules.locations = asList(await api.oaGet('/profile/attendance/locations', this.token))
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
const pack = await api.oaGet('/system/attend-rules', this.token)
|
||||||
|
if (pack && typeof pack === 'object') {
|
||||||
|
this.attendRules.workStart = pack.workStart || this.attendRules.workStart
|
||||||
|
this.attendRules.workEnd = pack.workEnd || this.attendRules.workEnd
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
this.attendance = asList(await api.oaGet('/profile/attendance', this.token))
|
||||||
|
} catch (_) {}
|
||||||
|
this.emit()
|
||||||
|
},
|
||||||
|
|
||||||
|
punch() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.getLocation({
|
||||||
|
type: 'gcj02',
|
||||||
|
isHighAccuracy: true,
|
||||||
|
success: async (pos) => {
|
||||||
|
try {
|
||||||
|
const rec = await api.oaPost('/profile/attendance/punch', {
|
||||||
|
source: 'mobile',
|
||||||
|
latitude: pos.latitude,
|
||||||
|
longitude: pos.longitude,
|
||||||
|
accuracyM: pos.accuracy
|
||||||
|
}, this.token)
|
||||||
|
await this.refreshAttendance()
|
||||||
|
resolve((rec && rec.title) || '打卡成功')
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail() {
|
||||||
|
reject(new Error('需要定位权限才能打卡'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async audit(no, action, comment) {
|
||||||
|
await api.oaPost(`/workflow/requests/${no}/audit`, { action, comment }, this.token)
|
||||||
|
await this.refreshWorkbench()
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitApply(body) {
|
||||||
|
await api.oaPost('/workflow/requests', body, this.token)
|
||||||
|
await this.refreshWorkbench()
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
try { imClient.logout() } catch (_) {}
|
||||||
|
uni.removeStorageSync(KEY_TOKEN)
|
||||||
|
this.token = ''
|
||||||
|
this.me = null
|
||||||
|
this.menus = {}
|
||||||
|
this.conversations = []
|
||||||
|
this.people = {}
|
||||||
|
this.phase = 'login'
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savedPhone() {
|
||||||
|
return uni.getStorageSync(KEY_PHONE) || ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 风影随行通讯 · 手机端交接(Windows)
|
||||||
|
|
||||||
|
手机端以后只在你这台 Windows 上改、测、打包。Linux 主机只跑现网 OA/IM,不再当手机开发机。
|
||||||
|
|
||||||
|
工程已在桌面时,用文末 **CMD 增量拉取** 覆盖服务器上的新文件即可,本地多出来的文件(例如 `unpackage/`、自己改过但服务器没有的)不会被删。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 分工
|
||||||
|
|
||||||
|
| 在哪 | 干什么 |
|
||||||
|
|---|---|
|
||||||
|
| **Windows + HBuilderX** | 改页面、联调、真机、打 APK |
|
||||||
|
| **Linux / 现网** | `oa.fysxkj.com` 后端、权限、IM。手机只调接口 |
|
||||||
|
|
||||||
|
现网地址(`utils/config.js`):
|
||||||
|
|
||||||
|
- OA:`https://oa.fysxkj.com`
|
||||||
|
- IM HTTP:`https://oa.fysxkj.com/im`
|
||||||
|
- IM WS:`wss://oa.fysxkj.com/im/websocket`
|
||||||
|
|
||||||
|
本地不用起后端、不用 npm、没有 `node_modules`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 权限(不要写死)
|
||||||
|
|
||||||
|
工作台入口只认 `/auth/my-perms`,和 Web 权限中心同一套。
|
||||||
|
|
||||||
|
- 判断:`utils/perms.js` 的 `canMenu`
|
||||||
|
- 目录(只描述「有这个权限才显示」):`utils/catalog.js`
|
||||||
|
- 登录后拉权:`utils/store.js` → `refreshPerms()` → `personalApps()` / `erpModules()`
|
||||||
|
|
||||||
|
谁看见打卡、审批、投标、人事,只由 Web 给这个人开的格子决定。不要按人名、岗位写死。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 工程结构
|
||||||
|
|
||||||
|
```
|
||||||
|
fysx-hbuilder/
|
||||||
|
pages.json 路由 / 底栏
|
||||||
|
manifest.json HBuilder 打包配置
|
||||||
|
App.vue / main.js
|
||||||
|
pages/ 页面(登录、消息、通讯录、打卡、工作台、我、聊天、审批、协同、ERP…)
|
||||||
|
utils/ 接口、权限、IM、申请单
|
||||||
|
components/ 顶栏
|
||||||
|
static/tab/ 底栏图标
|
||||||
|
交接.md 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
常改:
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `pages/tabs/workbench.vue` | 工作台(权限过滤后的格子) |
|
||||||
|
| `pages/login/login.vue` | 登录 |
|
||||||
|
| `utils/store.js` | 会话、IM、打卡、审批 |
|
||||||
|
| `utils/api.js` | HTTP |
|
||||||
|
| `utils/im.js` | WebSocket |
|
||||||
|
| `utils/catalog.js` | 工作台/ERP 入口元数据 |
|
||||||
|
| `utils/perms.js` | 权限判断 |
|
||||||
|
| `utils/config.js` | 现网地址 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. HBuilderX 打开
|
||||||
|
|
||||||
|
1. 安装 [HBuilderX](https://www.dcloud.io/hbuilderx.html)(正式版)
|
||||||
|
2. 登录 DCloud 账号(云打包要用)
|
||||||
|
3. **文件 → 导入 → 从本地目录导入** → 选桌面上的 `fysx-hbuilder`
|
||||||
|
4. 若提示重新获取 `appid`,确定即可;应用名保持「风影随行通讯」
|
||||||
|
|
||||||
|
已导入过的,增量拉取后在 HBuilderX 里重新打开该目录即可,不必再导入一遍。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 开发 / 测试
|
||||||
|
|
||||||
|
**H5 快测:** 运行 → 运行到浏览器 → Chrome。现网账号登录。看登录、消息、工作台格子是否随权限变。H5 定位不准,打卡以真机为准。
|
||||||
|
|
||||||
|
**真机:** USB 开开发者选项 + 调试 → 运行到手机 → Android。打卡必须允许定位。
|
||||||
|
|
||||||
|
**换两个权限不同的人登录**,工作台格子应不一样。没「投标管理」的人不应看到投标入口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 打包 APK
|
||||||
|
|
||||||
|
1. 发行 → 原生 App-云打包
|
||||||
|
2. Android,CPU 勾 `armeabi-v7a` + `arm64-v8a`
|
||||||
|
3. 证书:内测可用 DCloud 公共测试证书;上架再换自己的 keystore
|
||||||
|
4. 下载 APK 装到手机
|
||||||
|
|
||||||
|
打包产物在本机 `unpackage/`,增量拉取不会删它。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 什么时候才再碰 Linux
|
||||||
|
|
||||||
|
手机 UI / 打包:不用。
|
||||||
|
|
||||||
|
只有改后端接口、权限、IM 协议,或要把 APK 挂到现网下载页时才上服务器。
|
||||||
|
|
||||||
|
可选(Windows CMD,把打好的包挂现网):
|
||||||
|
|
||||||
|
```bat
|
||||||
|
scp D:\你的包.apk root@47.96.23.244:/data/nexus-oa-erp/dist/downloads/android.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验收清单
|
||||||
|
|
||||||
|
1. 登录(短信码仅服务端开启时出现)
|
||||||
|
2. 消息列表:工作通知 / 群 / 单聊,能发文字
|
||||||
|
3. 换账号后工作台入口不同
|
||||||
|
4. 无模块权限时 ERP 页提示无权限
|
||||||
|
5. 真机打卡能提交
|
||||||
|
6. 待办、请假/报销能走现网审批
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 桌面增量拉取(CMD)
|
||||||
|
|
||||||
|
在 **Windows CMD** 执行。会下载最新包并解压覆盖桌面上的 `fysx-hbuilder`(只覆盖包里有的文件)。
|
||||||
|
|
||||||
|
```bat
|
||||||
|
cd /d %USERPROFILE%\Desktop && curl -L -o fysx-hbuilder.zip https://oa.fysxkj.com/downloads/fysx-hbuilder.zip && tar -xf fysx-hbuilder.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
拉完打开:`%USERPROFILE%\Desktop\fysx-hbuilder`
|
||||||