优化uniapp

This commit is contained in:
hero920103 committed 2026-11-11 16:58:30 +08:00
1 parent 4f1acaa451
commit 8fc52c574f
49 files changed
+1952 -197

No files matched your search

+1 -1
View File
@@ -221,7 +221,7 @@ const orgLine = computed(() => {
if (user.value.tenant) parts.push(user.value.tenant)
if (user.value.account) parts.push(user.value.account)
if (user.value.role_name) parts.push(user.value.role_name)
return parts.length ? parts.join(' · ') : '云泽统一账号'
return parts.length ? parts.join(' · ') : '温新云统一账号'
})
const badgeText = computed(() => (stats.value.message > 99 ? '99+' : String(stats.value.message || '')))
+1 -1
View File
@@ -20,7 +20,7 @@
<view class="brand-logo">
<FaIcon name="shield-halved" color="#ffffff" :size="22" />
</view>
<text class="brand-name">云泽统一认证</text>
<text class="brand-name">温新云统一认证</text>
<text class="brand-desc">一次登录,全平台通行</text>
</view>
+454 -107
View File
@@ -4,18 +4,18 @@
<view class="head" :style="headPad">
<view class="head-row">
<view>
<text class="head-eyebrow">YUNZE WORKSPACE</text>
<text class="head-eyebrow">WENXIN WORKSPACE</text>
<text class="head-title">工作台</text>
</view>
<view v-if="!guest" class="account-chip"><FaIcon name="user" color="#3478f6" :size="15" /><text>{{ user.account || '我的账号' }}</text></view>
<view v-else class="account-chip" @tap="goLogin"><FaIcon name="right-to-bracket" color="#3478f6" :size="15" /><text>登录</text></view>
<view v-else class="account-chip"><FaIcon name="user" color="#3478f6" :size="15" /><text>游客</text></view>
</view>
<view v-if="!guest" class="tenant-switch" @tap="chooseTenant">
<view class="tenant-mark"><FaIcon name="building" color="#ffffff" :size="19" /></view>
<view class="tenant-copy"><text class="tenant-label">当前租户</text><text class="tenant-name">{{ user.tenant || '云泽平台' }}</text></view>
<view class="tenant-copy"><text class="tenant-label">当前企业</text><text class="tenant-name">{{ user.tenant || '尚未加入企业' }}</text></view>
<view class="tenant-action"><text>切换</text><FaIcon name="chevron-down" color="#3478f6" :size="12" /></view>
</view>
<text class="head-sub">{{ guest ? '免登录可逛部分功能,登录后使用完整工作台' : '选择一个业务系统,开始高效工作' }}</text>
<!-- <text class="head-sub">{{ guest ? '免登录可逛部分功能,登录后使用完整工作台' : '选择一个业务系统,开始高效工作' }}</text> -->
</view>
<view class="body">
@@ -36,8 +36,28 @@
</view>
<view v-else class="grid">
<!-- #ifdef MP-WEIXIN -->
<!-- 未授权手机号:卡片本身就是微信授权按钮,点一下直接拉起手机号授权 -->
<button
v-for="card in cards"
v-show="guest"
:key="'phone-' + (card.code || card.id)"
class="cell cell-button"
open-type="getPhoneNumber"
@getphonenumber="onCardPhone($event, card)"
>
<view class="portal-card" :class="{ locked: card.locked }">
<view class="tint-icon portal-icon" :class="card.tint">
<FaIcon :name="card.icon" color="#ffffff" :size="26" />
</view>
<text class="portal-name">{{ card.name }}</text>
</view>
</button>
<!-- #endif -->
<!-- 已授权(注册即登录):普通卡片,按模块是否需要企业分流 -->
<view
v-for="card in cards"
v-show="!guest || !isMp"
:key="card.code || card.id"
class="cell"
@tap="enter(card)"
@@ -47,7 +67,6 @@
<FaIcon :name="card.icon" color="#ffffff" :size="26" />
</view>
<text class="portal-name">{{ card.name }}</text>
<text v-if="guest" class="portal-tag" :class="card.needLogin ? 'need' : 'free'">{{ card.needLogin ? '需登录' : '免登录' }}</text>
</view>
</view>
</view>
@@ -58,11 +77,58 @@
<!-- #ifdef MP-WEIXIN -->
<!-- 游客无感授权:无弹窗,全屏透明按钮——首次点按即拉起微信原生手机号确认层;
后端按手机号自动判定:绑定过租户→直接登录,否则→游客令牌 -->
<view v-if="guestAuthVisible" class="guest-auth-mask">
<!-- <view v-if="guestAuthVisible" class="guest-auth-mask">
<button class="guest-auth-full" open-type="getPhoneNumber" :disabled="guestAuthLoading" @getphonenumber="onGuestPhone" />
<text class="guest-auth-later" @tap="skipGuestAuth">暂不,先浏览</text>
</view>
</view> -->
<!-- #endif -->
<!-- 企业选择:自绘底部抽屉。原生 actionSheet 只有一行居中文本,长企业名会挤在一起,
也无法标注「当前企业」;这里给每项独立卡片 + 两行标题 + 状态标识 -->
<view v-if="tenantPickerVisible" class="tenant-picker-mask" @tap="closeTenantPicker">
<view class="tenant-picker-sheet" @tap.stop>
<view class="picker-grabber" />
<view class="picker-head">
<view class="picker-head-icon"><FaIcon name="building" color="#ffffff" :size="18" /></view>
<view class="picker-head-copy">
<text class="picker-title">{{ tenantPickerMode === 'login' ? '选择要进入的企业' : '切换企业' }}</text>
<text class="picker-sub">{{ tenantPickerMode === 'login' ? '该账号已加入多家企业,请选择工作空间' : '选择一家企业,工作台将随之刷新' }}</text>
</view>
<view v-if="!tenantPickerRequired" class="picker-close" @tap="closeTenantPicker">
<FaIcon name="xmark" color="#94a3b8" :size="14" />
</view>
</view>
<scroll-view class="picker-list" scroll-y :show-scrollbar="false">
<view
v-for="(item, index) in tenantPickerList"
:key="item.tid"
class="tenant-option"
:class="{ current: isCurrentTenant(item), disabled: isPickingAny() }"
@tap="pickTenant(item)"
>
<view class="tenant-option-badge" :class="'badge-' + (index % 4)">
<FaIcon name="building" color="#ffffff" :size="18" />
</view>
<view class="tenant-option-copy">
<text class="tenant-option-name">{{ item.tenant_name || '企业' + item.tid }}</text>
<text class="tenant-option-meta">{{ item.name ? item.name + ' · ' : '' }}企业 ID {{ item.tid }}</text>
</view>
<text v-if="isCurrentTenant(item)" class="tenant-current-tag">当前</text>
<text v-else-if="isPickingTenant(item)" class="tenant-option-loading">进入中…</text>
<FaIcon v-else name="chevron-right" color="#cbd5e1" :size="14" />
</view>
</scroll-view>
<view class="picker-foot">
<view v-if="tenantPickerRequired" class="picker-hint">
<FaIcon name="circle-info" color="#94a3b8" :size="12" />
<text>选择企业后才能继续使用工作台</text>
</view>
<view v-else class="picker-cancel" @tap="closeTenantPicker"><text>取消</text></view>
</view>
</view>
</view>
</view>
</template>
@@ -73,8 +139,10 @@ import { mpHeadPad } from '@/utils/mpHead.js'
const headPad = mpHeadPad()
import { onShow } from '@dcloudio/uni-app'
import { isLoggedIn, getTenants, getTid, setAuthTokens, setTenants, setTid, setTenantName, getUser, setUser } from '@/utils/auth.js'
import { switchTenant } from '@/api/auth.js'
import { fetchHome, fetchPublicModules, fetchPublicMenus } from '@/api/workspace.js'
import { switchTenant, loginByMiniapp } from '@/api/auth.js'
import { fetchHome, fetchPublicModules, fetchPublicMenus, clearHomeCache } from '@/api/workspace.js'
import { onboardingPhone, getOnboardingStatus } from '@/api/onboarding.js'
import { setPendingCard, takePendingCard } from '@/utils/onboarding.js'
import { openMenu } from '@/utils/nav.js'
import { getSubsystemByModule, goSubsystem } from '@/config/subsystems.js'
import { getGuestToken, setGuestToken, clearGuestToken } from '@/utils/guest.js'
@@ -86,6 +154,12 @@ const guestAuthVisible = ref(false)
const guestAuthLoading = ref(false)
// 本次停留内跳过:关闭后不在 nav 反复弹窗,下次冷启动再引导
const guestAuthDismissed = ref(false)
const phoneBusy = ref(false)
// 非微信端没有 getPhoneNumber 能力:卡片退化为普通点击并给出提示
let isMp = false
// #ifdef MP-WEIXIN
isMp = true
// #endif
/** 仅微信小程序可拿手机号动态凭证 */
function canRequestGuestPhone() {
@@ -170,36 +244,83 @@ async function completeUserLogin(res) {
uni.showToast({ title: '已登录,欢迎回来', icon: 'none' })
}
/** 待选状态下必须选一个企业才能继续,关闭选择器后再次拉起 */
/* ==================== 企业选择抽屉(替代 uni.showActionSheet) ==================== */
// 原生 actionSheet 只有一行居中文本:长企业名挤成一片、无法标注「当前企业」。
// 登录后的首次选择必须选一家(不可关闭),主动切换可取消,两者共用这一套抽屉。
const tenantPickerVisible = ref(false)
const tenantPickerRequired = ref(false)
const tenantPickerMode = ref('login') // login=登录后的首次选择 / switch=主动切换
const tenantPickerList = ref([])
const tenantPickingTid = ref('')
function openTenantPicker(list, mode, required) {
tenantPickerList.value = Array.isArray(list) ? list : []
tenantPickerMode.value = mode
tenantPickerRequired.value = required
tenantPickingTid.value = ''
tenantPickerVisible.value = true
}
function closeTenantPicker() {
if (tenantPickingTid.value) return
if (tenantPickerRequired.value) {
uni.showToast({ title: '请选择一家企业以继续', icon: 'none' })
return
}
tenantPickerVisible.value = false
}
function isCurrentTenant(item) {
return tenantPickerMode.value === 'switch' && item && String(item.tid) === String(getTid())
}
function isPickingAny() {
return tenantPickingTid.value !== ''
}
function isPickingTenant(item) {
return item && tenantPickingTid.value === String(item.tid)
}
/** 待选状态下必须选一家企业才能继续 */
function askChooseTenant(list) {
uni.showActionSheet({
itemList: list.map((t) => t.tenant_name || `企业${t.tid}`),
success: async ({ tapIndex }) => {
const tenant = list[tapIndex]
if (!tenant) return
uni.showLoading({ title: '登录中...', mask: true })
try {
const sw = await switchTenant({ tid: tenant.tid })
if (!sw || !sw.tokens) throw new Error((sw && sw.msg) || '进入企业失败')
setAuthTokens(sw.tokens)
setTenants(list)
setTid(tenant.tid)
setTenantName(tenant.tenant_name || '')
// 先游客后转正:选定企业后移植游客数据
await mergeGuestIfAny()
await load(true)
uni.showToast({ title: '已登录,欢迎回来', icon: 'none' })
} catch (e) {
uni.showToast({ title: (e && e.message) || '进入失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
}
},
fail: () => {
uni.showToast({ title: '请选择要进入的企业', icon: 'none' })
setTimeout(() => askChooseTenant(list), 800)
openTenantPicker(list, 'login', true)
}
async function pickTenant(item) {
if (!item || isPickingAny() || isCurrentTenant(item)) return
const isSwitch = tenantPickerMode.value === 'switch'
tenantPickingTid.value = String(item.tid)
switchingTenant.value = true
uni.showLoading({ title: isSwitch ? '正在切换...' : '登录中...', mask: true })
try {
const res = await switchTenant({ tid: item.tid })
if (!res || !res.tokens) throw new Error((res && res.msg) || (isSwitch ? '切换企业失败' : '进入企业失败'))
setAuthTokens(res.tokens)
setTenants(tenantPickerList.value)
setTid(item.tid)
setTenantName(item.tenant_name || '')
if (isSwitch) {
const profile = getUser() || {}
const nextUser = { ...profile, tid: item.tid, tenant_name: item.tenant_name || '' }
setUser(nextUser)
user.value = { ...user.value, tenant: item.tenant_name || '', account: nextUser.account || user.value.account }
}
})
// 成功才关闭;失败保留抽屉让用户直接重试
tenantPickerRequired.value = false
tenantPickerVisible.value = false
// 先游客后转正:企业落定后把游客数据并入正式账号
await mergeGuestIfAny()
await load(true)
uni.showToast({ title: isSwitch ? '已切换企业' : '已登录,欢迎回来', icon: isSwitch ? 'success' : 'none' })
} catch (e) {
console.error('[nav] switch tenant failed:', e)
uni.showToast({ title: (e && e.message) || '进入失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
tenantPickingTid.value = ''
switchingTenant.value = false
}
}
function skipGuestAuth() {
@@ -262,17 +383,17 @@ const cards = computed(() => {
if (guest.value) {
return publicModules.value.map((mod, index) => {
const code = String(mod.code || '').toLowerCase()
const needLogin = !guestMenuModuleSet.value.has(code)
const needTenant = mod.require_login !== 0
return {
id: mod.id,
code: mod.code,
path: mod.path || '',
name: mod.name || '未命名系统',
desc: mod.description || (needLogin ? '需登录使用' : '免登录'),
desc: mod.description || (needTenant ? '加入企业后可用' : '可直接使用'),
icon: MODULE_ICONS[code] || 'cube',
tint: 'tint-' + (index % 8),
locked: false,
needLogin,
needTenant,
items: [],
total: 0
}
@@ -303,6 +424,7 @@ const cards = computed(() => {
icon: MODULE_ICONS[code] || 'cube',
tint: locked ? 'tint-off' : 'tint-' + (index % 8),
locked,
needTenant: mod.require_login !== 0,
items,
total: items.length
}
@@ -329,8 +451,7 @@ async function load(force = false) {
} finally {
loading.value = false
}
// 进小程序即引导授权:游客无令牌时弹手机号授权窗
maybeAskGuestAuth()
// 新流程:不再自动弹授权,点任意模块卡片时才拉起微信手机号授权
return
}
try {
@@ -352,56 +473,123 @@ async function load(force = false) {
function chooseTenant() {
const list = tenants.value
if (list.length <= 1) { uni.showToast({ title: list.length ? '当前账号仅绑定一个租户' : '暂无可切换的租户', icon: 'none' })
if (list.length <= 1) { uni.showToast({ title: list.length ? '当前账号仅绑定一个企业' : '暂无可切换的企业', icon: 'none' })
return
}
if (switchingTenant.value) return
uni.showActionSheet({
itemList: list.map((tenant) => tenant.tenant_name || `企业${tenant.tid}`),
success: async ({ tapIndex }) => {
const tenant = list[tapIndex]
if (!tenant || String(tenant.tid) === String(getTid())) return
switchingTenant.value = true
uni.showLoading({ title: '正在切换...', mask: true })
try {
const res = await switchTenant({ tid: tenant.tid })
if (!res || !res.tokens) throw new Error((res && res.msg) || '切换租户失败')
setAuthTokens(res.tokens)
setTid(tenant.tid)
setTenantName(tenant.tenant_name || '')
const profile = getUser() || {}
const nextUser = { ...profile, tid: tenant.tid, tenant_name: tenant.tenant_name || '' }
setUser(nextUser)
user.value = { ...user.value, tenant: tenant.tenant_name || '', account: nextUser.account || user.value.account }
await load(true)
uni.showToast({ title: '已切换租户', icon: 'success' })
} catch (e) {
uni.showToast({ title: (e && e.message) || '切换失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
switchingTenant.value = false
}
if (switchingTenant.value || tenantPickerVisible.value) return
openTenantPicker(list, 'switch', false)
}
/* ==================== 手机号授权 / 注册 / 企业分流 ==================== */
/** 点卡片(未授权时)直接触发微信手机号授权;有账号静默登录,无账号去设密码注册 */
async function onCardPhone(e, card) {
const detail = (e && e.detail) || {}
if (String(detail.errMsg || '').indexOf('ok') === -1 || !detail.code) {
uni.showToast({ title: '需要授权手机号才能使用', icon: 'none' })
return
}
if (phoneBusy.value) return
phoneBusy.value = true
uni.showLoading({ title: '验证中...', mask: true })
try {
const code = await navUniLoginCode()
let appid = ''
try {
appid = (uni.getAccountInfoSync().miniProgram || {}).appId || ''
} catch (err) {
appid = ''
}
})
setPendingCard(card)
const res = await onboardingPhone({ code, phoneCode: detail.code, appid })
if (res && res.need_register) {
uni.hideLoading()
uni.navigateTo({
url: '/pages/onboarding/register?token=' + encodeURIComponent(res.register_token || '') +
'&mobile_mask=' + encodeURIComponent(res.mobile_mask || '')
})
return
}
// 已有账号:复用小程序一键登录(后端按手机号认人并绑定 openid)
const ready = await silentLogin(code, detail.code)
if (!ready) return
await load(true)
await enter(card)
takePendingCard()
} catch (err) {
console.error('[nav] onboarding phone failed:', err)
uni.showToast({ title: (err && err.message) || '授权失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
phoneBusy.value = false
}
}
/** 已有账号静默登录;无企业时后端签发 tid=0 令牌,多企业时拉起企业选择抽屉 */
async function silentLogin(code, phoneCode) {
const res = await loginByMiniapp({ code, phoneCode })
if (!res || !res.tokens || !res.tokens.access_token) {
throw new Error((res && res.msg) || '登录失败,请重试')
}
setAuthTokens(res.tokens)
clearHomeCache()
const list = Array.isArray(res.tenants) ? res.tenants : []
setTenants(list)
if (res.need_choose_tenant && list.length > 1) {
askChooseTenant(list)
return false
}
const t0 = list[0] || {}
setTid(t0.tid || 0)
setTenantName(t0.tenant_name || '')
await mergeGuestIfAny()
return true
}
/**
* 点击子系统卡片 → 进入该子系统独立页面组(带自己的 tabbar)。
* 未在 config/subsystems.js 注册的模块,降级为直接打开 Web 端该模块首页。
* 游客:命中「必须登录」模块时不直接进,先引导登录(登录后回 nav)。
* 需企业模块:查审核状态,未通过则引导创建 / 挂靠企业;通过后刷新企业令牌再进入。
*/
function enter(card) {
async function enter(card) {
if (card.locked) {
openMenu({ locked: true, title: card.name })
return
}
if (guest.value && card.needLogin) {
goLogin(card.name)
if (guest.value) {
uni.showToast({ title: '请先授权手机号', icon: 'none' })
return
}
if (card.needTenant) {
let st = null
try {
st = await getOnboardingStatus()
} catch (e) {
uni.showToast({ title: '状态获取失败,请重试', icon: 'none' })
return
}
if (st && st.has_tenant) {
// 审核刚通过时本地令牌可能仍是 tid=0,重新静默登录拿企业令牌
if (!getTid()) {
try {
const code = await navUniLoginCode()
const ready = await silentLogin(code, '')
if (!ready) return
await load(true)
} catch (e) {
uni.showToast({ title: '登录态刷新失败,请重试', icon: 'none' })
return
}
}
} else {
// 不暂存卡片:避免企业申请页返回工作台时 onShow 再次自动跳回,形成循环
uni.navigateTo({ url: '/pages/onboarding/company' })
return
}
}
if (goSubsystemByModule(card.code)) return
if (card.items.length) {
openMenu(card.items[0], card.name)
const items = card.items || []
if (items.length) {
openMenu(items[0], card.name)
return
}
if (card.path) {
@@ -411,24 +599,20 @@ function enter(card) {
uni.showToast({ title: card.name + '暂未开放', icon: 'none' })
}
/** 引导登录:登录页登录成功后会回 nav(见 login.vue finishLogin) */
function goLogin(name = '') {
if (name) {
uni.showToast({ title: '「' + name + '」需登录后使用', icon: 'none' })
}
uni.navigateTo({ url: '/pages/login/login' })
}
/* ==================== 生命周期 ==================== */
onMounted(() => {
// 默认落地页:未登录不再强制回登录页,而是以游客态渲染公开模块宫格
load()
})
onShow(() => {
// 登录态可能在登录页/其他入口发生变化,每次展示重新判定并加载
// 注册 / 企业申请页回来后登录态可能变化,重新判定并加载
if (!loading.value) load()
// 注册完成后自动继续进入用户刚才点击的模块
const pending = takePendingCard()
if (pending && isLoggedIn()) {
setTimeout(() => enter(pending), 600)
}
})
</script>
@@ -627,26 +811,20 @@ onShow(() => {
color: $color-text-muted;
}
/* 游客态:卡片左上角标出免登录 / 需登录 */
.portal-card {
position: relative;
}
.portal-tag {
position: absolute;
top: 14rpx;
right: 14rpx;
padding: 2rpx 12rpx;
border-radius: 20rpx;
font-size: 18rpx;
line-height: 1.6;
}
.portal-tag.free {
color: #16a34a;
background: rgba(22, 163, 74, 0.12);
}
.portal-tag.need {
color: #94a3b8;
background: rgba(148, 163, 184, 0.16);
/* 未授权时卡片是微信授权按钮:抹平 button 默认样式,保持与 view 卡片一致 */
.cell-button {
display: block;
margin: 0 0 20rpx 0;
padding: 0 20rpx 0 0;
background: transparent;
border: none;
border-radius: 0;
line-height: normal;
text-align: left;
&::after {
border: none;
}
}
.bottom-space {
@@ -692,4 +870,173 @@ onShow(() => {
color: #94a3b8;
padding: 16rpx;
}
/* ============ 企业选择抽屉(自绘,替代原生 actionSheet) ============ */
.tenant-picker-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: flex-end;
background: rgba(15, 23, 42, .48);
@include mask-responsive;
}
.tenant-picker-sheet {
width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, .12);
padding-bottom: env(safe-area-inset-bottom);
animation: tenant-sheet-in .22s ease-out;
@include sheet-responsive;
}
@keyframes tenant-sheet-in {
from { transform: translateY(60rpx); opacity: .6; }
to { transform: translateY(0); opacity: 1; }
}
.picker-grabber {
width: 72rpx;
height: 8rpx;
margin: 16rpx auto 4rpx;
border-radius: $radius-full;
background: #dbe3ef;
}
.picker-head {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx $page-padding-x 24rpx;
border-bottom: 1rpx solid $color-divider;
}
.picker-head-icon {
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20rpx;
background: linear-gradient(135deg, #60a5fa, #2563eb);
box-shadow: 0 8rpx 18rpx rgba(37, 99, 235, .25);
}
.picker-head-copy { flex: 1; min-width: 0; }
.picker-title { display: block; font-size: 32rpx; font-weight: 700; letter-spacing: .5rpx; color: $color-text; }
.picker-sub { display: block; margin-top: 8rpx; font-size: 22rpx; line-height: 1.5; color: $color-text-muted; }
.picker-close {
width: 56rpx;
height: 56rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
background: $color-bg-page;
}
.picker-list {
flex: 1;
min-height: 0;
max-height: 52vh;
padding: 24rpx $page-padding-x 4rpx;
box-sizing: border-box;
}
.tenant-option {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 20rpx;
padding: 24rpx 22rpx;
border: 1rpx solid #e6edf8;
border-radius: 20rpx;
background: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(38, 86, 150, .05);
transition: transform .15s ease, border-color .15s ease, background .15s ease;
&:active { transform: scale(.985); background: #f7faff; border-color: #b9d2ff; }
&.current { border-color: #bfd4ff; background: #f5f9ff; }
&.disabled { opacity: .55; }
}
.tenant-option-badge {
width: 76rpx;
height: 76rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20rpx;
background: linear-gradient(135deg, #60a5fa, #2563eb);
&.badge-1 { background: linear-gradient(135deg, #34d399, #0ea5a4); }
&.badge-2 { background: linear-gradient(135deg, #a78bfa, #6366f1); }
&.badge-3 { background: linear-gradient(135deg, #fbbf6e, #f97316); }
}
.tenant-option-copy { flex: 1; min-width: 0; }
.tenant-option-name {
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 28rpx;
font-weight: 600;
line-height: 1.45;
color: $color-text;
}
.tenant-option-meta {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tenant-current-tag {
flex-shrink: 0;
padding: 6rpx 16rpx;
border-radius: $radius-full;
background: $color-primary-bg;
font-size: 20rpx;
color: $color-primary;
}
.tenant-option-loading { flex-shrink: 0; font-size: 22rpx; color: $color-primary; }
.picker-foot { padding: 8rpx $page-padding-x 20rpx; }
.picker-cancel {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
background: #f1f5f9;
font-size: 28rpx;
font-weight: 500;
color: $color-text-secondary;
&:active { background: #e6ecf5; }
}
.picker-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
height: 72rpx;
font-size: 22rpx;
color: $color-text-muted;
}
</style>
+402
View File
@@ -0,0 +1,402 @@
<template>
<view class="page">
<view class="hero">
<text class="eyebrow">YUNZE WORKSPACE</text>
<text class="title">加入企业</text>
<text class="desc">使用业务功能需要先创建企业,或申请挂靠已有企业</text>
</view>
<!-- 审核状态 -->
<view v-if="status" class="status-card" :class="statusClass">
<text class="status-title">{{ statusTitle }}</text>
<text class="status-desc">{{ statusDesc }}</text>
</view>
<view class="tabs">
<view class="tab" :class="{ active: tab === 'create' }" @tap="tab = 'create'">创建企业</view>
<view class="tab" :class="{ active: tab === 'join' }" @tap="tab = 'join'">挂靠企业</view>
</view>
<!-- ============ 创建企业:沿用完整字段与后端校验 ============ -->
<view v-if="tab === 'create'" class="card">
<view class="field">
<text class="label">企业全称 <text class="required">*</text></text>
<input v-model="createForm.tenant_name" class="input" placeholder="营业执照上的公司全称" />
</view>
<view class="field">
<text class="label">统一社会信用代码 <text class="required">*</text></text>
<input v-model="createForm.credit_code" class="input" :maxlength="18" placeholder="18 位大写字母或数字" />
</view>
<view class="field">
<text class="label">法定代表人 <text class="required">*</text></text>
<input v-model="createForm.legal_person" class="input" placeholder="姓名" />
</view>
<view class="field">
<text class="label">营业执照 <text class="required">*</text></text>
<view class="upload" @tap="chooseLicense">
<text>{{ licenseName || '点击上传 JPG / PNG,不超过 5MB' }}</text>
</view>
</view>
<view class="field">
<text class="label">联系人 <text class="required">*</text></text>
<input v-model="createForm.contact_person" class="input" placeholder="姓名" />
</view>
<view class="field">
<text class="label">联系电话 <text class="required">*</text></text>
<input v-model="createForm.contact_phone" class="input" type="number" :maxlength="11" placeholder="手机号" />
</view>
<view class="field">
<text class="label">电子邮箱 <text class="required">*</text></text>
<input v-model="createForm.contact_email" class="input" placeholder="用于接收审核结果" />
</view>
<view class="field">
<text class="label">企业地址</text>
<input v-model="createForm.address" class="input" placeholder="选填" />
</view>
<text class="hint">提交后由平台管理员审核,通过后自动成为该企业管理员</text>
</view>
<!-- ============ 挂靠企业 ============ -->
<view v-else class="card">
<view class="field">
<text class="label">企业全称 <text class="required">*</text></text>
<view class="search-row">
<input v-model="keyword" class="input search-input" placeholder="输入企业全称后搜索" />
<view class="search-btn" @tap="searchTenants">搜索</view>
</view>
<view v-if="tenantList.length" class="result-list">
<view v-for="t in tenantList" :key="t.tid" class="result-row" @tap="chooseTenant(t)">
<text>{{ t.tenant_name }}</text>
</view>
</view>
<view v-else-if="searched" class="no-result">未找到企业,请核对全称或改走「创建企业」</view>
<text v-if="selectedTenant" class="selected">已选:{{ selectedTenant.tenant_name }}</text>
</view>
<view class="field">
<text class="label">部门 <text class="required">*</text></text>
<picker :range="deptNames" :value="deptIndex" :disabled="!selectedTenant || !deptNames.length" @change="onDeptChange">
<view class="picker-value">{{ deptName() }}</view>
</picker>
</view>
<view class="field">
<text class="label">职位 <text class="required">*</text></text>
<picker :range="positionNames" :value="positionIndex" :disabled="!positionNames.length" @change="onPositionChange">
<view class="picker-value">{{ positionName() }}</view>
</picker>
</view>
<view class="field">
<text class="label">姓名 <text class="required">*</text></text>
<input v-model="applicantName" class="input" placeholder="真实姓名" />
</view>
<view class="field">
<text class="label">手机号</text>
<view class="readonly"><text>{{ mobileMask || '微信已验证' }}</text><text class="readonly-tag">不可修改</text></view>
</view>
<text class="hint">提交后由该企业管理员审核,通过后即可使用业务功能</text>
</view>
<view v-if="errorMsg" class="error"><text>{{ errorMsg }}</text></view>
<button class="submit" :disabled="loading || (status && status.has_tenant)" @tap="handleSubmit">
{{ loading ? '提交中…' : (tab === 'create' ? '提交创建申请' : '提交挂靠申请') }}
</button>
<view class="back" @tap="goBack"><text>返回工作台</text></view>
</view>
</template>
<script setup>
/**
* 需企业模块的引导页:创建企业(平台审核) / 挂靠企业(租户管理员审核)。
* 手机号由后端从微信验证结果读取,前端只读展示,不接受输入。
*/
import { ref, reactive, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import {
getOnboardingStatus, searchOnboardingTenants, getOnboardingDepartments,
getOnboardingPositions, applyCreate, applyJoin
} from '@/api/onboarding.js'
import { getBaseURL } from '@/api/request.js'
const tab = ref('create')
const status = ref(null)
const loading = ref(false)
const errorMsg = ref('')
const mobileMask = ref('')
const createForm = reactive({
tenant_name: '', credit_code: '', legal_person: '', license_file: '',
contact_person: '', contact_phone: '', contact_email: '', address: ''
})
const licenseName = ref('')
const keyword = ref('')
const searched = ref(false)
const tenantList = ref([])
const selectedTenant = ref(null)
const deptList = ref([])
const positionList = ref([])
const deptIndex = ref(0)
const positionIndex = ref(0)
const applicantName = ref('')
const deptNames = computed(() => deptList.value.map((d) => d.name))
const positionNames = computed(() => positionList.value.map((p) => p.name))
const statusClass = computed(() => {
if (!status.value) return ''
if (status.value.has_tenant) return 'passed'
const a = status.value.apply
if (!a) return ''
return a.status === 0 ? 'pending' : (a.status === 2 ? 'rejected' : 'passed')
})
const statusTitle = computed(() => {
if (!status.value) return ''
if (status.value.has_tenant) return '已加入 ' + (status.value.tenant_name || '企业')
const a = status.value.apply
if (!a) return '尚未提交申请'
if (a.status === 0) return '审核中'
if (a.status === 2) return '申请已驳回'
return '审核已通过'
})
const statusDesc = computed(() => {
if (!status.value) return ''
if (status.value.has_tenant) return '返回工作台即可使用业务功能'
const a = status.value.apply
if (!a) return '请选择「创建企业」或「挂靠企业」并提交资料'
if (a.status === 0) return '请等待管理员审核,通过后即可使用业务功能'
if (a.status === 2) return (a.reject_reason || '资料不符合要求') + ',可修改后重新提交'
return '审核已通过,返回工作台重新进入即可'
})
onShow(() => { loadStatus() })
async function loadStatus() {
try {
const res = await getOnboardingStatus()
status.value = res || null
mobileMask.value = (res && res.mobile_mask) || ''
} catch (e) {
console.warn('[onboarding] status failed:', e)
}
}
function goBack() {
uni.reLaunch({ url: '/pages/nav/nav' })
}
function chooseLicense() {
uni.chooseImage({
count: 1,
success: (res) => {
const filePath = res.tempFilePaths && res.tempFilePaths[0]
if (!filePath) return
uni.showLoading({ title: '上传中...', mask: true })
uni.uploadFile({
url: getBaseURL().replace(/\/$/, '') + '/auth/register/upload-license',
filePath,
name: 'file',
success: (r) => {
try {
const body = JSON.parse(r.data || '{}')
if (body.code === 200 && body.data && body.data.url) {
createForm.license_file = body.data.url
licenseName.value = body.data.name || '已上传'
} else {
uni.showToast({ title: body.msg || '上传失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '上传失败', icon: 'none' })
}
},
fail: () => uni.showToast({ title: '上传失败', icon: 'none' }),
complete: () => uni.hideLoading()
})
}
})
}
async function searchTenants() {
const kw = keyword.value.trim()
if (!kw) {
errorMsg.value = '请输入企业全称'
return
}
errorMsg.value = ''
try {
const res = await searchOnboardingTenants(kw)
tenantList.value = (res && res.list) || []
searched.value = true
selectedTenant.value = null
deptList.value = []
positionList.value = []
} catch (e) {
errorMsg.value = (e && e.message) || '企业搜索失败'
}
}
async function chooseTenant(t) {
selectedTenant.value = t
keyword.value = t.tenant_name
deptIndex.value = 0
positionIndex.value = 0
positionList.value = []
try {
const res = await getOnboardingDepartments(t.tid)
deptList.value = (res && res.list) || []
if (deptList.value.length) await loadPositions()
} catch (e) {
errorMsg.value = (e && e.message) || '部门列表加载失败'
}
}
async function loadPositions() {
const dept = deptList.value[deptIndex.value]
if (!selectedTenant.value || !dept) return
try {
const res = await getOnboardingPositions(selectedTenant.value.tid, dept.id)
positionList.value = (res && res.list) || []
positionIndex.value = 0
} catch (e) {
errorMsg.value = (e && e.message) || '职位列表加载失败'
}
}
function onDeptChange(e) {
deptIndex.value = Number(e.detail.value) || 0
loadPositions()
}
function onPositionChange(e) {
positionIndex.value = Number(e.detail.value) || 0
}
function deptName() {
const d = deptList.value[deptIndex.value]
return d ? d.name : (selectedTenant.value ? '请选择部门' : '请先搜索并选择企业')
}
function positionName() {
const p = positionList.value[positionIndex.value]
return p ? p.name : (deptList.value.length ? '请选择职位' : '请先选择部门')
}
async function handleSubmit() {
if (loading.value) return
errorMsg.value = ''
if (status.value && status.value.has_tenant) {
goBack()
return
}
if (tab.value === 'create') await submitCreate()
else await submitJoin()
}
async function submitCreate() {
const f = createForm
f.tenant_name = f.tenant_name.trim()
f.credit_code = f.credit_code.trim().toUpperCase()
if (!f.tenant_name || !/^[0-9A-Z]{18}$/.test(f.credit_code) || !f.legal_person.trim() ||
!f.license_file || !f.contact_person.trim() || !/^1\d{10}$/.test(f.contact_phone.trim()) ||
!f.contact_email.includes('@')) {
errorMsg.value = '请完整填写企业信息:信用代码 18 位、营业执照、法人、联系人、11 位手机号与邮箱为必填'
return
}
loading.value = true
try {
await applyCreate({
tenant_name: f.tenant_name,
credit_code: f.credit_code,
legal_person: f.legal_person.trim(),
license_file: f.license_file,
contact_person: f.contact_person.trim(),
contact_phone: f.contact_phone.trim(),
contact_email: f.contact_email.trim(),
address: f.address.trim()
})
uni.showToast({ title: '已提交,等待平台审核', icon: 'none' })
await loadStatus()
} catch (e) {
errorMsg.value = (e && e.message) || '提交失败,请重试'
} finally {
loading.value = false
}
}
async function submitJoin() {
if (!selectedTenant.value) {
errorMsg.value = '请先搜索并选择要挂靠的企业'
return
}
const dept = deptList.value[deptIndex.value]
const pos = positionList.value[positionIndex.value]
if (!dept || !pos || !applicantName.value.trim()) {
errorMsg.value = '请选择部门、职位并填写姓名'
return
}
loading.value = true
try {
await applyJoin({
tid: selectedTenant.value.tid,
applicant_name: applicantName.value.trim(),
department_id: dept.id,
department_name: dept.name,
position_id: pos.id,
position_name: pos.name
})
uni.showToast({ title: '已提交,等待企业管理员审核', icon: 'none' })
await loadStatus()
} catch (e) {
errorMsg.value = (e && e.message) || '提交失败,请重试'
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page { min-height: 100vh; padding: 0 $page-padding-x 60rpx; box-sizing: border-box; background: #f5f8fd; }
.hero { padding: 100rpx 8rpx 40rpx; }
.eyebrow { display: block; font-size: 20rpx; font-weight: 700; letter-spacing: 3rpx; color: #6e9de5; }
.title { display: block; margin-top: 14rpx; font-size: 44rpx; font-weight: 700; color: #17345f; }
.desc { display: block; margin-top: 14rpx; font-size: 25rpx; line-height: 1.6; color: #64748b; }
.status-card { @include card; padding: 26rpx 28rpx; margin-bottom: 28rpx; border-left: 8rpx solid #94a3b8; }
.status-card.pending { border-left-color: #f59e0b; }
.status-card.rejected { border-left-color: #ef4444; }
.status-card.passed { border-left-color: #10b981; }
.status-title { display: block; font-size: 30rpx; font-weight: 700; color: #0f172a; }
.status-desc { display: block; margin-top: 8rpx; font-size: 23rpx; line-height: 1.6; color: #64748b; }
.tabs { display: flex; gap: 12rpx; padding: 8rpx; margin-bottom: 24rpx; border-radius: $radius-full; background: #e8eef8; }
.tab { flex: 1; height: 76rpx; display: flex; align-items: center; justify-content: center; border-radius: $radius-full; font-size: 27rpx; color: #64748b; }
.tab.active { background: #ffffff; color: #2563eb; font-weight: 700; box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, .08); }
.card { @include card; padding: 32rpx 28rpx 36rpx; }
.field { margin-bottom: 26rpx; }
.label { display: block; margin-bottom: 12rpx; font-size: 25rpx; font-weight: 600; color: #334155; }
.required { color: #ef4444; }
.input { height: 84rpx; padding: 0 22rpx; border: 1rpx solid #dce7f7; border-radius: $radius-md; background: #fbfdff; font-size: 27rpx; color: $color-text; box-sizing: border-box; }
.hint { display: block; font-size: 22rpx; line-height: 1.6; color: $color-text-muted; }
.error { margin-top: 24rpx; font-size: 24rpx; color: $color-danger; }
.upload { min-height: 88rpx; padding: 24rpx 22rpx; border: 1rpx dashed #b9d2ff; border-radius: $radius-md; background: #f7faff; font-size: 24rpx; color: #2563eb; }
.search-row { display: flex; gap: 14rpx; }
.search-input { flex: 1; }
.search-btn { width: 140rpx; height: 84rpx; display: flex; align-items: center; justify-content: center; border-radius: $radius-md; background: #2563eb; color: #fff; font-size: 26rpx; }
.result-list { margin-top: 14rpx; border: 1rpx solid #e2e8f0; border-radius: $radius-md; overflow: hidden; }
.result-row { padding: 20rpx 22rpx; font-size: 26rpx; color: #334155; border-bottom: 1rpx solid #eef2f7; }
.result-row:last-child { border-bottom: none; }
.no-result { margin-top: 14rpx; font-size: 23rpx; color: #94a3b8; }
.selected { display: block; margin-top: 14rpx; font-size: 24rpx; color: #2563eb; }
.picker-value { height: 84rpx; line-height: 84rpx; padding: 0 22rpx; border: 1rpx solid #dce7f7; border-radius: $radius-md; background: #fbfdff; font-size: 27rpx; color: $color-text; }
.readonly { height: 84rpx; display: flex; align-items: center; justify-content: space-between; padding: 0 22rpx; border: 1rpx solid #e2e8f0; border-radius: $radius-md; background: #f1f5f9; font-size: 27rpx; color: #475569; }
.readonly-tag { font-size: 21rpx; color: #94a3b8; }
.submit { margin-top: 36rpx; height: 92rpx; line-height: 92rpx; border: none; border-radius: $radius-full; background: linear-gradient(135deg, #60a5fa, #2563eb); color: #fff; font-size: 30rpx; font-weight: 600; box-shadow: 0 10rpx 24rpx rgba(37, 99, 235, .22);
&::after { border: none; }
&[disabled] { opacity: .6; }
}
.back { margin-top: 28rpx; text-align: center; font-size: 25rpx; color: #94a3b8; }
</style>
+150
View File
@@ -0,0 +1,150 @@
<template>
<view class="page">
<view class="hero">
<text class="eyebrow">YUNZE WORKSPACE</text>
<text class="title">设置登录密码</text>
<text class="desc">手机号 {{ mobileMask || '已验证' }} 尚未注册,设置密码后即可使用工作台</text>
</view>
<view class="card">
<view class="field">
<text class="label">登录密码</text>
<input v-model="password" class="input" type="password" placeholder="至少 6 位" :maxlength="32" />
</view>
<view class="field">
<text class="label">确认密码</text>
<input v-model="confirmPassword" class="input" type="password" placeholder="再输入一次" :maxlength="32" />
</view>
<text class="hint">密码至少 6 位;微信号会在注册时自动绑定,下次可直接进入</text>
<view v-if="errorMsg" class="error"><text>{{ errorMsg }}</text></view>
<button class="submit" :disabled="loading" @tap="handleSubmit">
{{ loading ? '注册中…' : '完成注册' }}
</button>
</view>
</view>
</template>
<script setup>
/**
* 手机号授权后未查到账号:设置密码完成注册(后端同时绑定微信 openid 并签发 tid=0 令牌)。
* 注册完成后回到工作台,由 nav 自动继续进入刚才点击的模块。
*/
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { onboardingRegister } from '@/api/onboarding.js'
import { setAuthTokens, setTid, setTenantName } from '@/utils/auth.js'
import { guestMerge } from '@/api/guest.js'
import { getGuestToken, clearGuestToken } from '@/utils/guest.js'
import { clearHomeCache } from '@/api/workspace.js'
const token = ref('')
const mobileMask = ref('')
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const errorMsg = ref('')
onLoad((options = {}) => {
token.value = options.token || ''
mobileMask.value = decodeURIComponent(options.mobile_mask || '')
if (!token.value) {
errorMsg.value = '授权会话已失效,请返回工作台重新授权手机号'
}
})
async function handleSubmit() {
if (loading.value) return
errorMsg.value = ''
if (!token.value) {
errorMsg.value = '授权会话已失效,请返回工作台重新授权手机号'
return
}
if (password.value.length < 6) {
errorMsg.value = '密码长度不能少于 6 位'
return
}
if (password.value !== confirmPassword.value) {
errorMsg.value = '两次输入的密码不一致'
return
}
loading.value = true
try {
const res = await onboardingRegister({
token: token.value,
password: password.value,
confirmPassword: confirmPassword.value
})
if (!res || !res.tokens || !res.tokens.access_token) throw new Error('注册失败,未返回登录态')
setAuthTokens(res.tokens)
setTid(0)
setTenantName('')
clearHomeCache()
// 注册前以游客身份产生的数据并入正式账号
if (getGuestToken()) {
try {
await guestMerge()
} catch (e) {
console.warn('[onboarding] guest merge failed:', e)
}
clearGuestToken()
}
uni.showToast({ title: '注册成功', icon: 'success' })
setTimeout(() => uni.reLaunch({ url: '/pages/nav/nav' }), 500)
} catch (e) {
errorMsg.value = (e && e.message) || '注册失败,请重试'
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
padding: 0 $page-padding-x 60rpx;
box-sizing: border-box;
background: linear-gradient(160deg, #eaf3ff 0%, #f8fafc 42%, #f8fafc 100%);
}
.hero { padding: 120rpx 8rpx 56rpx; }
.eyebrow { display: block; font-size: 20rpx; font-weight: 700; letter-spacing: 3rpx; color: #6e9de5; }
.title { display: block; margin-top: 14rpx; font-size: 46rpx; font-weight: 700; color: #17345f; }
.desc { display: block; margin-top: 16rpx; font-size: 25rpx; line-height: 1.6; color: #64748b; }
.card {
@include card;
padding: 36rpx 30rpx 40rpx;
}
.field { margin-bottom: 28rpx; }
.label { display: block; margin-bottom: 12rpx; font-size: 25rpx; font-weight: 600; color: #334155; }
.input {
height: 88rpx;
padding: 0 24rpx;
border: 1rpx solid #dce7f7;
border-radius: $radius-md;
background: #fbfdff;
font-size: 28rpx;
color: $color-text;
box-sizing: border-box;
}
.hint { display: block; font-size: 22rpx; line-height: 1.6; color: $color-text-muted; }
.error { margin-top: 20rpx; font-size: 24rpx; color: $color-danger; }
.submit {
margin-top: 40rpx;
height: 92rpx;
line-height: 92rpx;
border: none;
border-radius: $radius-full;
background: linear-gradient(135deg, #60a5fa, #2563eb);
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
box-shadow: 0 10rpx 24rpx rgba(37, 99, 235, .22);
&::after { border: none; }
&[disabled] { opacity: .6; }
}
</style>
+2 -2
View File
@@ -6,7 +6,7 @@
<text class="avatar-text">{{ avatarText }}</text>
</view>
<view class="profile-detail">
<text class="profile-name">{{ user?.nickname || user?.name || '云泽用户' }}</text>
<text class="profile-name">{{ user?.nickname || user?.name || '温新云用户' }}</text>
<text class="profile-id">{{ user?.account ? `账号: ${user.account}` : `ID: ${userId}` }}</text>
</view>
<view class="profile-edit" @tap="onEdit">
@@ -120,7 +120,7 @@ async function loadUser() {
const merged = {
...prev,
...data,
nickname: data.name || data.nickname || data.account || prev.nickname || '云泽用户'
nickname: data.name || data.nickname || data.account || prev.nickname || '温新云用户'
}
setUser(merged)
user.value = merged