主要更新uniapp相关功能
This commit is contained in:
1 parent
aa92ca40a2
commit
5b4dbb2b46
65 files changed
+8532
-1921
No files matched your search
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- ============ Hero 头部 ============ -->
|
||||
<view class="hero">
|
||||
<view class="hero" :style="headPad">
|
||||
<view class="hero-glow hero-glow-1" />
|
||||
<view class="hero-glow hero-glow-2" />
|
||||
<view class="hero-top">
|
||||
@@ -45,7 +45,7 @@
|
||||
<FaIcon name="rotate" color="#f04438" :size="16" />
|
||||
</view>
|
||||
|
||||
<!-- 应用宫格:选卡片进入对应功能系统 -->
|
||||
<!-- 应用:按模块分组,每组一个 card,组头 + 组内一行 4 个功能 -->
|
||||
<view class="block">
|
||||
<view class="block-head">
|
||||
<text class="block-title">应用</text>
|
||||
@@ -59,31 +59,44 @@
|
||||
<text class="empty-text">尚未配置应用模块</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!loadError" class="app-grid">
|
||||
<block v-else-if="!loadError">
|
||||
<view
|
||||
v-for="group in moduleGroups"
|
||||
:key="group.code || group.id"
|
||||
class="app-card"
|
||||
:class="{ locked: group.locked }"
|
||||
@tap="onModuleTap(group)"
|
||||
:key="group.id"
|
||||
class="group-card"
|
||||
>
|
||||
<view class="app-card-top">
|
||||
<view class="tint-icon app-icon" :class="group.tint">
|
||||
<FaIcon :name="group.icon" color="#ffffff" :size="24" />
|
||||
</view>
|
||||
<text v-if="group.locked" class="app-lock">未开通</text>
|
||||
<view class="group-head" @tap="onModuleTap(group)">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">{{ group.name }}</text>
|
||||
<text v-if="group.locked" class="group-lock">未开通</text>
|
||||
<text class="group-count">{{ group.total }}</text>
|
||||
</view>
|
||||
<text class="app-name">{{ group.name }}</text>
|
||||
<text class="app-desc">{{ group.desc }}</text>
|
||||
<view class="app-foot">
|
||||
<text class="app-count">{{ group.total }} 个功能</text>
|
||||
<FaIcon name="arrow-right" color="#c8cfdf" :size="12" />
|
||||
<view v-if="group.items.length" class="grid">
|
||||
<view
|
||||
v-for="(item, index) in group.items"
|
||||
:key="item.id"
|
||||
class="cell"
|
||||
@tap="openMenu(item, item.title)"
|
||||
>
|
||||
<view class="fn-card" :class="{ locked: group.locked || item.locked }">
|
||||
<view
|
||||
class="tint-icon fn-icon"
|
||||
:class="group.locked || item.locked ? 'tint-off' : 'tint-' + (index % 8)"
|
||||
>
|
||||
<FaIcon :name="item.icon || group.icon" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<text class="fn-name">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="group-empty">
|
||||
<text class="group-empty-text">{{ group.locked ? '未开通该模块' : '该模块下暂无你可见的功能' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 常用应用 -->
|
||||
<!-- 常用功能:宫格卡(一行 4 个) -->
|
||||
<view class="block">
|
||||
<view class="block-head">
|
||||
<text class="block-title">常用功能</text>
|
||||
@@ -95,17 +108,24 @@
|
||||
<view v-if="!loading && !loadError && quickApps.length === 0" class="empty-box">
|
||||
<text class="empty-text">暂无可用应用,请联系管理员开通</text>
|
||||
</view>
|
||||
<view v-else-if="!loadError" class="grid">
|
||||
<view
|
||||
v-for="item in quickApps"
|
||||
:key="item.key"
|
||||
class="grid-item"
|
||||
@tap="openMenu(item.menu, item.name)"
|
||||
>
|
||||
<view class="tint-icon grid-icon" :class="item.tint">
|
||||
<FaIcon :name="item.icon" color="#ffffff" :size="22" />
|
||||
<view v-else-if="!loadError" class="quick-card">
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="(item, index) in quickApps"
|
||||
:key="item.key"
|
||||
class="cell"
|
||||
@tap="openMenu(item.menu, item.name)"
|
||||
>
|
||||
<view class="fn-card" :class="{ locked: item.locked }">
|
||||
<view
|
||||
class="tint-icon fn-icon"
|
||||
:class="item.locked ? 'tint-off' : 'tint-' + (index % 8)"
|
||||
>
|
||||
<FaIcon :name="item.icon" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<text class="fn-name">{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="grid-name">{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -165,6 +185,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
|
||||
const headPad = mpHeadPad()
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
import { fetchHome } from '@/api/workspace.js'
|
||||
@@ -172,24 +195,12 @@ import { openMenu, resolveNativeRoute } from '@/utils/nav.js'
|
||||
import { request } from '@/api/request.js'
|
||||
import AppTabbar from '@/components/MainTabbar.vue'
|
||||
|
||||
/** 模块 code → 端上图标(后端 icon 存的是 Element Plus 图标名,端上用不了) */
|
||||
const MODULE_ICONS = {
|
||||
crm: 'handshake',
|
||||
erp: 'boxes-stacked',
|
||||
oa: 'clipboard-check',
|
||||
cms: 'globe',
|
||||
ai: 'robot',
|
||||
system: 'gear',
|
||||
tool: 'wrench'
|
||||
}
|
||||
|
||||
/** 公共菜单(无 module 归属)的协作工具图标 */
|
||||
const TOOL_ICONS = ['note-sticky', 'calendar-days', 'file-lines', 'clock-rotate-left', 'chart-line']
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const user = ref({})
|
||||
const modules = ref([])
|
||||
const menus = ref([])
|
||||
const stats = ref({ todo: 0, overdue: 0, note: 0, message: 0, reimburse: 0 })
|
||||
const pendingList = ref([])
|
||||
@@ -218,66 +229,75 @@ const badgeText = computed(() => (stats.value.message > 99 ? '99+' : String(stat
|
||||
/** 叶子菜单(可直接跳转的功能页) */
|
||||
const leafMenus = computed(() => menus.value.filter((m) => m.leaf !== false && !m.locked))
|
||||
|
||||
/**
|
||||
* 按「一级分类目录」归组(与子系统功能页 SubFunctions.vue 一致):
|
||||
* 菜单层级形如 模块根 → 个人办公 → 通讯录,分组取一级目录名(个人办公 / 人事管理 …);
|
||||
* 模块根仅作容器被跳过;无分类归属的顶层功能统一归入「常用功能」。
|
||||
* 祖先链需在「全部菜单」里查找(目录不是叶子,不在 leafMenus 中)。
|
||||
*/
|
||||
const moduleGroups = computed(() => {
|
||||
const byCode = {}
|
||||
leafMenus.value.forEach((m) => {
|
||||
if (!m.module) return
|
||||
if (!byCode[m.module]) byCode[m.module] = []
|
||||
byCode[m.module].push(m)
|
||||
const byId = {}
|
||||
menus.value.forEach((m) => {
|
||||
byId[m.id] = m
|
||||
})
|
||||
// 只有叶子菜单才是可点功能;目录只作分组标题
|
||||
const leaves = leafMenus.value.slice().sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
|
||||
// 已登记的功能模块(type=1)。type=2 是系统设置(角色/日志/字典/域名…),
|
||||
// 属于后台管理范畴,不适合摆在工作台上,留给「全部应用」页。
|
||||
const groups = modules.value
|
||||
.filter((m) => m.type !== 2)
|
||||
.map((mod, index) => {
|
||||
const items = byCode[mod.code] || []
|
||||
const code = String(mod.code || '').toLowerCase()
|
||||
const locked = mod.enabled === false
|
||||
return {
|
||||
id: mod.id,
|
||||
code: mod.code,
|
||||
path: mod.path || '',
|
||||
name: mod.name || '未命名应用',
|
||||
desc: locked
|
||||
? '未开通该模块'
|
||||
: mod.description || (items.length ? items.length + ' 个功能' : '暂无功能'),
|
||||
icon: MODULE_ICONS[code] || 'cube',
|
||||
tint: locked ? 'tint-off' : 'tint-' + (index % 8),
|
||||
locked,
|
||||
items: items.slice(0, 6),
|
||||
total: items.length
|
||||
}
|
||||
})
|
||||
|
||||
// 兜底:模块表里没有对应记录(或已删除)的菜单,避免被静默丢弃
|
||||
const known = {}
|
||||
groups.forEach((g) => {
|
||||
known[String(g.code || '').toLowerCase()] = true
|
||||
})
|
||||
const orphan = []
|
||||
leafMenus.value.forEach((m) => {
|
||||
const code = String(m.module || '').toLowerCase()
|
||||
if (code && !known[code] && code !== 'other') orphan.push(m)
|
||||
})
|
||||
if (orphan.length) {
|
||||
groups.push({
|
||||
id: 'other',
|
||||
code: 'other',
|
||||
name: '其他功能',
|
||||
desc: orphan.length + ' 个功能',
|
||||
icon: 'file-lines',
|
||||
tint: 'tint-7',
|
||||
locked: false,
|
||||
items: orphan.slice(0, 6),
|
||||
total: orphan.length
|
||||
})
|
||||
// 取该菜单所属的「一级分组」节点(自底向上找到链上最顶层节点)
|
||||
function groupNodeOf(m) {
|
||||
const chain = [m]
|
||||
let cur = m
|
||||
let guard = 0
|
||||
while (byId[cur.pid] && guard++ < 20) {
|
||||
cur = byId[cur.pid]
|
||||
chain.unshift(cur)
|
||||
}
|
||||
const top = chain[0]
|
||||
if (top.pid === 0) {
|
||||
// 模块根在列表内:跳过根,取它下一层(个人办公 / 人事管理 …)
|
||||
if (chain.length >= 2) return chain[1]
|
||||
// 顶层节点自身:目录容器跳过,叶子归入「常用功能」
|
||||
return top.leaf === false ? null : top
|
||||
}
|
||||
// 模块根不在列表内:链上顶层节点本身就是一级分组
|
||||
return top
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
const groupMap = new Map()
|
||||
const common = { id: 'common', name: '常用功能', sort: -1, icon: 'star', items: [] }
|
||||
|
||||
const enabledCount = computed(() => moduleGroups.value.filter((g) => !g.locked).length)
|
||||
leaves.forEach((m) => {
|
||||
const g = groupNodeOf(m)
|
||||
if (!g) return
|
||||
if (g.id === m.id) {
|
||||
// 无分类归属的顶层散装功能
|
||||
common.items.push(m)
|
||||
return
|
||||
}
|
||||
if (!groupMap.has(g.id)) {
|
||||
groupMap.set(g.id, { id: g.id, name: g.title, sort: g.sort, icon: g.icon, items: [] })
|
||||
}
|
||||
groupMap.get(g.id).items.push(m)
|
||||
})
|
||||
|
||||
const dirGroups = []
|
||||
groupMap.forEach((g) => dirGroups.push(g))
|
||||
dirGroups.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
|
||||
const all = []
|
||||
if (common.items.length) all.push(common)
|
||||
all.push(...dirGroups)
|
||||
|
||||
// 工作台只展示 2 行(一行 4 个),更多功能留给「全部应用」页
|
||||
return all.map((group, gi) => ({
|
||||
...group,
|
||||
icon: group.icon || 'file-lines',
|
||||
tint: 'tint-' + (gi % 8),
|
||||
locked: false,
|
||||
total: group.items.length,
|
||||
items: group.items.slice(0, 8)
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* 协作工具:真正无模块归属的公共菜单(如后续新增的通用功能)。
|
||||
@@ -323,7 +343,6 @@ async function loadHome() {
|
||||
try {
|
||||
const data = await fetchHome()
|
||||
user.value = data.user || {}
|
||||
modules.value = Array.isArray(data.modules) ? data.modules : []
|
||||
menus.value = Array.isArray(data.menus) ? data.menus : []
|
||||
stats.value = Object.assign({ todo: 0, overdue: 0, note: 0, message: 0, reimburse: 0 }, data.stats || {})
|
||||
loadError.value = ''
|
||||
@@ -380,10 +399,6 @@ function onModuleTap(group) {
|
||||
openMenu(group.items[0], group.name)
|
||||
return
|
||||
}
|
||||
if (group.path) {
|
||||
openMenu({ title: group.name, path: group.path, leaf: true }, group.name)
|
||||
return
|
||||
}
|
||||
goAllApps()
|
||||
}
|
||||
|
||||
@@ -466,6 +481,9 @@ onShow(() => {
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
padding: calc(var(--status-bar-height, 44px) + 28rpx) $page-padding-x 76rpx;
|
||||
/* #ifdef MP-WEIXIN */
|
||||
padding-top: calc(env(safe-area-inset-top, 25px) + 92rpx);
|
||||
/* #endif */
|
||||
background: $hero-gradient;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -700,214 +718,115 @@ onShow(() => {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ============ 应用宫格卡片 ============ */
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
/* ============ 分组卡:外层一个 card,组头 + 组内一行 4 个功能 ============ */
|
||||
.group-card {
|
||||
@include card;
|
||||
padding: 26rpx 24rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.15s ease, box-shadow 0.2s ease;
|
||||
padding: 24rpx 20rpx 8rpx;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&.locked {
|
||||
.app-name,
|
||||
.app-desc {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
& + & {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
@include tint-icon(88rpx, 24rpx);
|
||||
}
|
||||
|
||||
.app-lock {
|
||||
@include chip(#fef3f2, $color-danger);
|
||||
}
|
||||
|
||||
.app-name {
|
||||
margin-top: 20rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-foot {
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 18rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
gap: 12rpx;
|
||||
padding: 0 4rpx 20rpx;
|
||||
}
|
||||
|
||||
.app-count {
|
||||
font-size: 22rpx;
|
||||
color: $color-primary;
|
||||
.group-bar {
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============ 常用功能宫格 ============ */
|
||||
.grid {
|
||||
@include card;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 28rpx 8rpx 20rpx;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
padding: 12rpx 0;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-icon {
|
||||
@include tint-icon(92rpx, 24rpx);
|
||||
}
|
||||
|
||||
.grid-name {
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============ 应用中心卡片 ============ */
|
||||
.mod-card {
|
||||
@include card;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
&.locked {
|
||||
.mod-name,
|
||||
.mod-desc {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mod-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.mod-icon {
|
||||
@include tint-icon(88rpx, 22rpx);
|
||||
}
|
||||
|
||||
.mod-meta {
|
||||
.group-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mod-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.mod-name {
|
||||
font-size: 30rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.mod-lock {
|
||||
@include chip(#fef3f2, $color-danger);
|
||||
}
|
||||
|
||||
.mod-desc {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
.group-lock {
|
||||
@include chip(#fef3f2, $color-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
.group-count {
|
||||
@include chip;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip-off {
|
||||
background: $color-divider;
|
||||
.group-empty {
|
||||
padding: 20rpx 4rpx 16rpx;
|
||||
}
|
||||
|
||||
.group-empty-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.chip-more {
|
||||
background: $color-primary-bg;
|
||||
}
|
||||
|
||||
/* ============ 协作工具 ============ */
|
||||
.tool-card {
|
||||
/* ============ 功能宫格:一行 4 个 ============
|
||||
cell 占 25% 宽并自带右侧间隙,容器用负 margin 抵消末列间隙,
|
||||
避免使用 calc 除法——小程序端 wxss 对复杂 calc 支持不稳。 */
|
||||
.quick-card {
|
||||
@include card;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 24rpx 8rpx;
|
||||
padding: 24rpx 20rpx 8rpx;
|
||||
}
|
||||
|
||||
.tool-item {
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-right: -16rpx;
|
||||
}
|
||||
|
||||
.cell {
|
||||
width: 25%;
|
||||
padding-right: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fn-card {
|
||||
height: 100%;
|
||||
padding: 18rpx 6rpx 16rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 10rpx 0;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
@include icon-box(72rpx);
|
||||
.fn-icon {
|
||||
@include tint-icon(64rpx, 18rpx);
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
.fn-name {
|
||||
max-width: 100%;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.3;
|
||||
color: $color-text;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fn-card.locked .fn-name {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ============ 列表(待办 / 动态) ============ */
|
||||
|
||||
+157
-170
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 吸顶搜索 -->
|
||||
<view class="search-wrap">
|
||||
<view class="search-wrap" :style="headPad">
|
||||
<view class="search-bar">
|
||||
<FaIcon name="magnifying-glass" color="#8a94ad" :size="16" />
|
||||
<input
|
||||
@@ -28,22 +28,21 @@
|
||||
<FaIcon name="magnifying-glass" color="#c0c4cc" :size="32" />
|
||||
<text class="empty-text">没有找到「{{ keyword.trim() }}」相关功能</text>
|
||||
</view>
|
||||
<view v-else class="list-card">
|
||||
<view
|
||||
v-for="item in searchResults"
|
||||
:key="item.menu.id"
|
||||
class="list-item"
|
||||
@tap="openMenu(item.menu, item.menu.title)"
|
||||
>
|
||||
<view class="row-icon" :class="item.locked ? 'tint-off' : item.tint">
|
||||
<FaIcon :name="item.icon" color="#ffffff" :size="16" />
|
||||
<view v-else class="group-card">
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="item in searchResults"
|
||||
:key="item.menu.id"
|
||||
class="cell"
|
||||
@tap="openMenu(item.menu, item.menu.title)"
|
||||
>
|
||||
<view class="fn-card" :class="{ locked: item.locked }">
|
||||
<view class="tint-icon fn-icon" :class="item.tint">
|
||||
<FaIcon :name="item.icon" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<text class="fn-name">{{ item.menu.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="row-body">
|
||||
<text class="row-title">{{ item.menu.title }}</text>
|
||||
<text class="row-sub">{{ item.moduleName }}</text>
|
||||
</view>
|
||||
<text v-if="item.locked" class="row-lock">未开通</text>
|
||||
<FaIcon v-else name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
@@ -55,67 +54,39 @@
|
||||
<text class="empty-text">尚未配置应用模块</text>
|
||||
</view>
|
||||
|
||||
<view v-for="group in groups" :key="group.code || group.id" class="group">
|
||||
<view v-for="group in groups" :key="group.id" class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="tint-icon group-icon" :class="group.tint">
|
||||
<FaIcon :name="group.icon" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<view class="group-meta">
|
||||
<view class="group-title-row">
|
||||
<text class="group-name">{{ group.name }}</text>
|
||||
<text v-if="group.locked" class="group-lock">未开通</text>
|
||||
</view>
|
||||
<text class="group-count">{{ group.items.length }} 个功能</text>
|
||||
<FaIcon :name="group.icon" color="#ffffff" :size="16" />
|
||||
</view>
|
||||
<text class="group-title">{{ group.name }}</text>
|
||||
<text v-if="group.locked" class="group-lock">未开通</text>
|
||||
<text class="group-count">{{ group.items.length }}</text>
|
||||
</view>
|
||||
|
||||
<view class="list-card">
|
||||
<view v-if="group.items.length" class="grid">
|
||||
<view
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
class="cell"
|
||||
@tap="openMenu(item, item.title)"
|
||||
>
|
||||
<view class="dot" :class="item.locked ? 'off' : 'primary'" />
|
||||
<view class="row-body">
|
||||
<text class="row-title">{{ item.title }}</text>
|
||||
<text v-if="item.path" class="row-sub">{{ item.path }}</text>
|
||||
<view class="fn-card" :class="{ locked: group.locked || item.locked }">
|
||||
<view
|
||||
class="tint-icon fn-icon"
|
||||
:class="group.locked || item.locked ? 'tint-off' : group.tint"
|
||||
>
|
||||
<FaIcon :name="item.icon || group.icon" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<text class="fn-name">{{ item.title }}</text>
|
||||
</view>
|
||||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
</view>
|
||||
<view v-if="group.items.length === 0" class="group-empty">
|
||||
<text class="group-empty-text">该模块下暂无你可见的功能</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="group-empty">
|
||||
<text class="group-empty-text">该模块下暂无你可见的功能</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="commonMenus.length" class="group">
|
||||
<view class="group-head">
|
||||
<view class="tint-icon group-icon tint-3">
|
||||
<FaIcon name="wrench" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<view class="group-meta">
|
||||
<view class="group-title-row">
|
||||
<text class="group-name">协作工具</text>
|
||||
</view>
|
||||
<text class="group-count">{{ commonMenus.length }} 个功能</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="list-card">
|
||||
<view
|
||||
v-for="item in commonMenus"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
@tap="openMenu(item, item.title)"
|
||||
>
|
||||
<view class="dot primary" />
|
||||
<view class="row-body">
|
||||
<text class="row-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view class="bottom-space" />
|
||||
@@ -127,6 +98,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
|
||||
const headPad = mpHeadPad()
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
import { fetchHome } from '@/api/workspace.js'
|
||||
@@ -148,51 +122,77 @@ const loading = ref(true)
|
||||
const modules = ref([])
|
||||
const menus = ref([])
|
||||
|
||||
/** 按模块 code 归组的全部菜单(含目录与锁定项,便于浏览) */
|
||||
/**
|
||||
* 按「一级分类目录」归组,与子系统功能页(SubFunctions.vue)保持一致:
|
||||
* 菜单层级形如 模块根 → 个人办公 → 通讯录,分组取一级目录名(个人办公 / 人事管理 …);
|
||||
* 模块根仅作容器被跳过;无分类归属的顶层功能统一归入「常用功能」。
|
||||
*/
|
||||
const groups = computed(() => {
|
||||
const byCode = {}
|
||||
const byId = {}
|
||||
menus.value.forEach((m) => {
|
||||
if (!m.module) return
|
||||
if (!byCode[m.module]) byCode[m.module] = []
|
||||
byCode[m.module].push(m)
|
||||
byId[m.id] = m
|
||||
})
|
||||
// 只有叶子菜单才是可点功能;目录只作为分组标题,避免二级目录被当成卡片塞进组内
|
||||
const leaves = menus.value
|
||||
.filter((m) => m.leaf !== false)
|
||||
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
|
||||
// 这里展示全部模块(含 type=2 系统设置),与工作台首页只展示业务模块形成分工
|
||||
const list = modules.value.map((mod, index) => ({
|
||||
id: mod.id,
|
||||
code: mod.code,
|
||||
name: mod.name || '未命名应用',
|
||||
icon: mod.type === 2 ? 'gear' : MODULE_ICONS[String(mod.code || '').toLowerCase()] || 'cube',
|
||||
tint: mod.enabled === false ? 'tint-off' : 'tint-' + (index % 8),
|
||||
locked: mod.enabled === false,
|
||||
items: (byCode[mod.code] || []).slice().sort((a, b) => a.sort - b.sort)
|
||||
}))
|
||||
|
||||
// 兜底:模块表里没有对应记录(或已删除)的菜单
|
||||
const known = {}
|
||||
list.forEach((g) => {
|
||||
known[String(g.code || '').toLowerCase()] = true
|
||||
})
|
||||
const orphan = []
|
||||
menus.value.forEach((m) => {
|
||||
const code = String(m.module || '').toLowerCase()
|
||||
if (code && !known[code]) orphan.push(m)
|
||||
})
|
||||
if (orphan.length) {
|
||||
list.push({
|
||||
id: 'other',
|
||||
code: 'other',
|
||||
name: '其他功能',
|
||||
icon: 'file-lines',
|
||||
tint: 'tint-7',
|
||||
locked: false,
|
||||
items: orphan
|
||||
})
|
||||
// 取该菜单所属的「一级分组」节点(自底向上找到链上最顶层节点)
|
||||
function groupNodeOf(m) {
|
||||
const chain = [m]
|
||||
let cur = m
|
||||
let guard = 0
|
||||
while (byId[cur.pid] && guard++ < 20) {
|
||||
cur = byId[cur.pid]
|
||||
chain.unshift(cur)
|
||||
}
|
||||
const top = chain[0]
|
||||
if (top.pid === 0) {
|
||||
// 模块根在列表内:跳过根,取它下一层(个人办公 / 人事管理 …)
|
||||
if (chain.length >= 2) return chain[1]
|
||||
// 顶层节点自身:目录容器跳过,叶子归入「常用功能」
|
||||
return top.leaf === false ? null : top
|
||||
}
|
||||
// 模块根不在列表内:链上顶层节点本身就是一级分组
|
||||
return top
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const commonMenus = computed(() => menus.value.filter((m) => !m.module))
|
||||
const groupMap = new Map()
|
||||
const common = { id: 'common', name: '常用功能', sort: -1, icon: 'star', items: [] }
|
||||
|
||||
leaves.forEach((m) => {
|
||||
const g = groupNodeOf(m)
|
||||
if (!g) return
|
||||
if (g.id === m.id) {
|
||||
// 无分类归属的顶层散装功能
|
||||
common.items.push(m)
|
||||
return
|
||||
}
|
||||
if (!groupMap.has(g.id)) {
|
||||
groupMap.set(g.id, { id: g.id, name: g.title, sort: g.sort, icon: g.icon, items: [] })
|
||||
}
|
||||
groupMap.get(g.id).items.push(m)
|
||||
})
|
||||
|
||||
const dirGroups = []
|
||||
groupMap.forEach((g) => dirGroups.push(g))
|
||||
dirGroups.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
|
||||
const all = []
|
||||
if (common.items.length) all.push(common)
|
||||
all.push(...dirGroups)
|
||||
|
||||
// 组级「未开通」:整组菜单均被锁定;图标底色按分组下标轮换
|
||||
return all.map((group, gi) => {
|
||||
const locked = group.items.length > 0 && group.items.every((it) => it.locked === true)
|
||||
return {
|
||||
...group,
|
||||
icon: group.icon || 'file-lines',
|
||||
tint: locked ? 'tint-off' : 'tint-' + (gi % 8),
|
||||
locked
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** 跨模块搜索:应用名 + 模块名 + 菜单标题 */
|
||||
const searchResults = computed(() => {
|
||||
@@ -257,6 +257,9 @@ onShow(() => {
|
||||
.search-wrap {
|
||||
flex-shrink: 0;
|
||||
padding: calc(var(--status-bar-height, 44px) + 16rpx) $page-padding-x 20rpx;
|
||||
/* #ifdef MP-WEIXIN */
|
||||
padding-top: calc(env(safe-area-inset-top, 25px) + 80rpx);
|
||||
/* #endif */
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
@@ -323,116 +326,100 @@ onShow(() => {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 32rpx;
|
||||
/* ============ 分组卡:外层一个 card,内含一行 4 个功能 card ============ */
|
||||
.group-card {
|
||||
@include card;
|
||||
padding: 24rpx 20rpx 8rpx;
|
||||
|
||||
& + & {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-bottom: 18rpx;
|
||||
padding: 0 4rpx;
|
||||
gap: 14rpx;
|
||||
padding: 0 4rpx 20rpx;
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
@include tint-icon(72rpx, 18rpx);
|
||||
@include tint-icon(56rpx, 14rpx);
|
||||
}
|
||||
|
||||
.group-meta {
|
||||
.group-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 30rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-lock {
|
||||
@include chip(#fef3f2, $color-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
@include chip;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
@include card;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
/* 功能格子:一行 4 个;cell 占 25% 并自带右间隙,容器负 margin 抵消末列间隙,
|
||||
避免使用 calc 除法——小程序端 wxss 对复杂 calc 支持不稳。 */
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 26rpx 24rpx;
|
||||
flex-wrap: wrap;
|
||||
margin-right: -16rpx;
|
||||
}
|
||||
|
||||
& + & {
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
.cell {
|
||||
width: 25%;
|
||||
padding-right: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fn-card {
|
||||
height: 100%;
|
||||
padding: 18rpx 6rpx 16rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
@include tint-icon(60rpx, 16rpx);
|
||||
.fn-icon {
|
||||
@include tint-icon(64rpx, 18rpx);
|
||||
}
|
||||
|
||||
.row-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
.fn-name {
|
||||
max-width: 100%;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
line-height: 1.3;
|
||||
color: $color-text;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-lock {
|
||||
@include chip(#fef3f2, $color-danger);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: $color-text-muted;
|
||||
|
||||
&.primary { background: $color-primary; }
|
||||
&.off { background: $color-border; }
|
||||
.fn-card.locked .fn-name {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.group-empty {
|
||||
padding: 40rpx 24rpx;
|
||||
text-align: center;
|
||||
padding: 20rpx 4rpx 16rpx;
|
||||
}
|
||||
|
||||
.group-empty-text {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<text class="nav-text">返回</text>
|
||||
</view>
|
||||
|
||||
<view class="header">
|
||||
<view class="header" :style="headPad">
|
||||
<text class="title">忘记密码</text>
|
||||
<text class="subtitle">
|
||||
{{ currentStep === 1 ? '验证您的账号' : currentStep === 2 ? '通过手机号验证身份' : '设置新密码' }}
|
||||
@@ -137,6 +137,9 @@
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onUnmounted } from 'vue'
|
||||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
|
||||
const headPad = mpHeadPad()
|
||||
import { verifyAccount, resetPassword, sendResetCode } from '@/api/auth.js'
|
||||
import { setTenantName, getTenantName } from '@/utils/auth.js'
|
||||
|
||||
@@ -319,6 +322,9 @@ onUnmounted(() => {
|
||||
.page-inner {
|
||||
padding: 0 48rpx;
|
||||
padding-top: calc(var(--status-bar-height, 44px) + 24rpx);
|
||||
/* #ifdef MP-WEIXIN */
|
||||
padding-top: calc(env(safe-area-inset-top, 25px) + 88rpx);
|
||||
/* #endif */
|
||||
padding-bottom: 60rpx;
|
||||
}
|
||||
|
||||
|
||||
+110
-60
@@ -30,7 +30,7 @@
|
||||
@tap="chooseTenant(item)"
|
||||
>
|
||||
<view class="tenant-avatar">
|
||||
<FaIcon name="building" color="#3973ff" :size="18" />
|
||||
<FaIcon name="building" color="#2563eb" :size="18" />
|
||||
</view>
|
||||
<view class="tenant-info">
|
||||
<text class="tenant-name">{{ item.tenant_name || `企业${item.tid}` }}</text>
|
||||
@@ -45,17 +45,40 @@
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ============ 账号密码登录 ============ -->
|
||||
<!-- ============ 登录(小程序端微信认证优先,其次账号密码;H5/APK 仅账号密码) ============ -->
|
||||
<block v-else>
|
||||
<view class="panel-head">
|
||||
<text class="panel-title">欢迎登录</text>
|
||||
<text class="panel-sub">请输入您的账号与密码以继续</text>
|
||||
<text class="panel-sub">{{ showMiniLogin ? '使用微信一键登录,或使用账号密码继续' : '请输入您的账号与密码以继续' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="errorMsg" class="error-box">
|
||||
<text class="error-text">{{ errorMsg }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 微信小程序:微信认证登录为主入口 -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<block v-if="showMiniLogin">
|
||||
<button
|
||||
class="btn-wechat"
|
||||
open-type="getPhoneNumber"
|
||||
:class="{ disabled: submitting }"
|
||||
@getphonenumber="onGetPhoneNumber"
|
||||
>
|
||||
<FaIcon name="weixin" type="brands" color="#ffffff" :size="18" />
|
||||
<text>微信手机号一键登录</text>
|
||||
</button>
|
||||
<view class="tip-row">
|
||||
<text class="tip-text">首次使用请先用账号密码登录一次,自动完成微信绑定后即可一键登录</text>
|
||||
</view>
|
||||
<view class="divider">
|
||||
<view class="divider-line" />
|
||||
<text class="divider-text">使用账号密码登录</text>
|
||||
<view class="divider-line" />
|
||||
</view>
|
||||
</block>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">账号</text>
|
||||
<view class="input-wrap">
|
||||
@@ -110,31 +133,6 @@
|
||||
<text class="agree-tip-text">请先阅读并勾选同意《用户协议》和《隐私政策》</text>
|
||||
</view>
|
||||
|
||||
<!-- 微信小程序一键登录 -->
|
||||
<block v-if="showMiniLogin">
|
||||
<view class="divider">
|
||||
<view class="divider-line" />
|
||||
<text class="divider-text">其他登录方式</text>
|
||||
<view class="divider-line" />
|
||||
</view>
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button
|
||||
class="btn-wechat"
|
||||
open-type="getPhoneNumber"
|
||||
:class="{ disabled: submitting }"
|
||||
@getphonenumber="onGetPhoneNumber"
|
||||
>
|
||||
<FaIcon name="weixin" type="brands" color="#ffffff" :size="18" />
|
||||
<text>微信手机号一键登录</text>
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="tip-row">
|
||||
<text class="tip-text">首次使用将自动绑定当前微信手机号对应的云泽账号</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view class="back-row">
|
||||
<text class="tip-text">还没有账号?</text>
|
||||
<text class="link" @tap="goRegister">立即注册</text>
|
||||
@@ -167,7 +165,8 @@ import {
|
||||
fetchUserInfo,
|
||||
getAuthVerifyConfig,
|
||||
getMiniappLoginConfig,
|
||||
loginByMiniapp
|
||||
loginByMiniapp,
|
||||
bindMiniapp
|
||||
} from '@/api/auth.js'
|
||||
import {
|
||||
loginSuccess,
|
||||
@@ -202,6 +201,18 @@ const verifyCfg = ref({ needGeetest: false, captchaId: '' })
|
||||
/** 仅微信小程序端且服务端已开通时展示「一键登录」入口 */
|
||||
const showMiniLogin = ref(false)
|
||||
|
||||
/** 运行端检测:h5 / apk(app-plus) / mp-weixin,决定登录方式优先级 */
|
||||
const platform = ref('')
|
||||
// #ifdef H5
|
||||
platform.value = 'h5'
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
platform.value = 'apk'
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
platform.value = 'mp-weixin'
|
||||
// #endif
|
||||
|
||||
onMounted(async () => {
|
||||
if (isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/nav/nav' })
|
||||
@@ -231,25 +242,33 @@ async function loadVerifyConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 小程序一键登录是否已开通(未开通则隐藏入口) */
|
||||
/** 小程序一键登录是否已开通(仅微信端拉取,未开通则回退账号密码登录) */
|
||||
async function loadMiniLoginConfig() {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (platform.value !== 'mp-weixin') return
|
||||
try {
|
||||
const data = await getMiniappLoginConfig()
|
||||
showMiniLogin.value = !!data?.enabled
|
||||
} catch {
|
||||
showMiniLogin.value = false
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/* ==================== 登录主流程 ==================== */
|
||||
|
||||
async function handleLogin() {
|
||||
if (submitting.value) return
|
||||
console.log('[login] tap handleLogin', { submitting: submitting.value, agreed: agreed.value })
|
||||
if (submitting.value) {
|
||||
uni.showToast({ title: '登录处理中,请稍候', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 未勾选协议:弹窗确认,确认后自动勾选并继续登录
|
||||
if (!agreed.value) {
|
||||
const ok = await confirmAgreement()
|
||||
let ok = false
|
||||
try {
|
||||
ok = await confirmAgreement()
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
if (!ok) {
|
||||
showAgreementTip.value = true
|
||||
return
|
||||
@@ -269,7 +288,8 @@ async function handleLogin() {
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
let captcha = null
|
||||
if (verifyCfg.value.needGeetest) {
|
||||
// 小程序端无极验 SDK:不弹验证,后端对 yz-miniapp 客户端也跳过校验
|
||||
if (verifyCfg.value.needGeetest && platform.value !== 'mp-weixin') {
|
||||
if (!verifyCfg.value.captchaId) {
|
||||
errorMsg.value = '人机验证未配置,请联系管理员'
|
||||
return
|
||||
@@ -376,6 +396,9 @@ async function finishLogin(tokens, tenantList, tid, fallbackAccount = '') {
|
||||
}
|
||||
})
|
||||
|
||||
// 微信端:登录成功后静默绑定 openid,认证中/无手机号能力时一键登录靠它认人
|
||||
await silentBindMiniapp()
|
||||
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
@@ -399,20 +422,35 @@ function uniLoginCode() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信端登录成功后静默把当前 openid 绑到该账号。
|
||||
* 小程序认证中 / 未开通手机号能力时,一键登录拿不到手机号,
|
||||
* 后端按这条绑定认人;绑定失败不阻塞登录。
|
||||
*/
|
||||
async function silentBindMiniapp() {
|
||||
if (platform.value !== 'mp-weixin') return
|
||||
try {
|
||||
const code = await uniLoginCode()
|
||||
await bindMiniapp({ code })
|
||||
} catch {
|
||||
/* 静默:不影响已完成的登录 */
|
||||
}
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
/** getPhoneNumber 授权回调:动态 code + uni.login code 一起换登录态 */
|
||||
async function onGetPhoneNumber(e) {
|
||||
if (submitting.value) return
|
||||
const detail = (e && e.detail) || {}
|
||||
if (detail.errMsg && detail.errMsg.indexOf('fail') !== -1) {
|
||||
if (detail.errMsg.indexOf('deny') !== -1) {
|
||||
uni.showToast({ title: '你已取消手机号授权', icon: 'none' })
|
||||
}
|
||||
const errMsg = detail.errMsg || ''
|
||||
if (errMsg.indexOf('deny') !== -1) {
|
||||
uni.showToast({ title: '你已取消手机号授权', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!detail.code) {
|
||||
uni.showToast({ title: '未获取到手机号凭证,请重试', icon: 'none' })
|
||||
return
|
||||
// 认证中 / 未开通手机号能力:拿不到动态 code 时降级为「按已绑定 openid 登录」
|
||||
const phoneCode = detail.code && errMsg.indexOf('fail') === -1 ? detail.code : ''
|
||||
if (!phoneCode) {
|
||||
uni.showToast({ title: '手机号能力暂不可用,按已绑定账号登录', icon: 'none' })
|
||||
}
|
||||
if (!agreed.value) {
|
||||
const ok = await confirmAgreement()
|
||||
@@ -426,7 +464,7 @@ async function onGetPhoneNumber(e) {
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const code = await uniLoginCode()
|
||||
const data = await loginByMiniapp({ code, phoneCode: detail.code })
|
||||
const data = await loginByMiniapp({ code, phoneCode })
|
||||
await handleAuthResult(data)
|
||||
} catch (err) {
|
||||
handleMiniError(err)
|
||||
@@ -441,12 +479,10 @@ function handleMiniError(err) {
|
||||
if (reason === 'identity_not_found') {
|
||||
uni.showModal({
|
||||
title: '尚未绑定云泽账号',
|
||||
content: '当前微信手机号未注册云泽账号,请先使用账号密码登录或注册后再使用一键登录。',
|
||||
content: '当前微信未绑定云泽账号,且手机号能力暂不可用。请先在下方用账号密码登录一次(会自动完成微信绑定),之后即可一键登录。',
|
||||
showCancel: false,
|
||||
confirmText: '去注册',
|
||||
success: (res) => {
|
||||
if (res.confirm) goRegister()
|
||||
}
|
||||
// 微信小程序 showModal 的 confirmText 上限为 4 个汉字,超限会导致 API 失败、弹窗不显示
|
||||
confirmText: '密码登录'
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -494,28 +530,42 @@ function showAgreement() {
|
||||
|
||||
/**
|
||||
* 未勾选协议时的二次确认弹窗。
|
||||
* 点「同意并登录」→ 自动勾选协议并返回 true,调用方继续登录;
|
||||
* 点「同意登录」→ 自动勾选协议并返回 true,调用方继续登录;
|
||||
* 点「再看看」→ 返回 false,停在当前页并高亮提示。
|
||||
* 兜底:部分端 showModal 既不回调 success 也不回调 fail,会导致 await 永久挂起、
|
||||
* 点击登录「没反应」,这里用超时保证一定会返回,绝不阻塞。
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function confirmAgreement() {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
let timer = null
|
||||
const finish = (val) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
resolve(val)
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
console.warn('[login] confirmAgreement 超时未回调,按未同意处理')
|
||||
finish(false)
|
||||
}, 8000)
|
||||
uni.showModal({
|
||||
title: '请确认用户协议',
|
||||
content: '登录即表示您已阅读并同意《用户协议》与《隐私政策》,是否同意并继续登录?',
|
||||
confirmText: '同意并登录',
|
||||
confirmText: '同意登录',
|
||||
cancelText: '再看看',
|
||||
confirmColor: '#3973ff',
|
||||
confirmColor: '#2563eb',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
agreed.value = true
|
||||
showAgreementTip.value = false
|
||||
resolve(true)
|
||||
finish(true)
|
||||
} else {
|
||||
resolve(false)
|
||||
finish(false)
|
||||
}
|
||||
},
|
||||
fail: () => resolve(false)
|
||||
fail: () => finish(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -530,7 +580,7 @@ function goForget() {
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(160deg, #1b3b8f 0%, #2f6fe4 52%, #52a8ff 100%);
|
||||
background: linear-gradient(160deg, #1e3a8a 0%, #2563eb 100%);
|
||||
padding: 0 32rpx calc(env(safe-area-inset-bottom, 0px) + 40rpx);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -703,7 +753,7 @@ function goForget() {
|
||||
.btn {
|
||||
height: 92rpx;
|
||||
border-radius: $radius-md;
|
||||
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #60a5fa 100%);
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
@@ -781,7 +831,7 @@ function goForget() {
|
||||
|
||||
.link {
|
||||
font-size: 26rpx;
|
||||
color: #3973ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* ---------- 企业选择 ---------- */
|
||||
@@ -801,7 +851,7 @@ function goForget() {
|
||||
background: #fbfcff;
|
||||
|
||||
&:active {
|
||||
border-color: #3973ff;
|
||||
border-color: #2563eb;
|
||||
background: #f5f9ff;
|
||||
}
|
||||
|
||||
@@ -878,8 +928,8 @@ function goForget() {
|
||||
height: 16rpx;
|
||||
margin-left: -5rpx;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
border-right: 3rpx solid #1b3b8f;
|
||||
border-bottom: 3rpx solid #1b3b8f;
|
||||
border-right: 3rpx solid #1e3a8a;
|
||||
border-bottom: 3rpx solid #1e3a8a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<text class="nav-text">返回</text>
|
||||
</view>
|
||||
|
||||
<view class="header">
|
||||
<view class="header" :style="headPad">
|
||||
<text class="title">注册账号</text>
|
||||
<text class="subtitle">按租户创建管理员账号</text>
|
||||
</view>
|
||||
@@ -107,6 +107,9 @@
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onUnmounted } from 'vue'
|
||||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
|
||||
const headPad = mpHeadPad()
|
||||
import { register, sendRegisterCode } from '@/api/auth.js'
|
||||
import { setTenantName } from '@/utils/auth.js'
|
||||
|
||||
@@ -222,6 +225,9 @@ onUnmounted(() => {
|
||||
.page-inner {
|
||||
padding: 0 48rpx;
|
||||
padding-top: calc(var(--status-bar-height, 44px) + 24rpx);
|
||||
/* #ifdef MP-WEIXIN */
|
||||
padding-top: calc(env(safe-area-inset-top, 25px) + 88rpx);
|
||||
/* #endif */
|
||||
padding-bottom: 60rpx;
|
||||
}
|
||||
|
||||
|
||||
+42
-77
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部信息:融入页面背景,不再单独做彩色抬头 -->
|
||||
<view class="head">
|
||||
<view class="head" :style="headPad">
|
||||
<view class="head-row">
|
||||
<text class="head-title">选择子系统</text>
|
||||
<text class="head-count">{{ enabledCount }}/{{ cards.length }} 已开通</text>
|
||||
@@ -20,28 +20,24 @@
|
||||
<FaIcon name="rotate" color="#f04438" :size="16" />
|
||||
</view>
|
||||
|
||||
<!-- 子系统宫格 -->
|
||||
<!-- 子系统分组卡:组内一行 4 个 -->
|
||||
<view v-else-if="!loading && cards.length === 0" class="empty-box">
|
||||
<FaIcon name="cube" color="#c0c4cc" :size="32" />
|
||||
<text class="empty-text">暂无子系统,请联系管理员开通</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="card-grid">
|
||||
<view v-else class="grid">
|
||||
<view
|
||||
v-for="card in cards"
|
||||
:key="card.code || card.id"
|
||||
class="sub-card"
|
||||
:class="{ locked: card.locked }"
|
||||
class="cell"
|
||||
@tap="enter(card)"
|
||||
>
|
||||
<view class="tint-icon sub-icon" :class="card.tint">
|
||||
<FaIcon :name="card.icon" color="#ffffff" :size="26" />
|
||||
</view>
|
||||
<text class="sub-name">{{ card.name }}</text>
|
||||
<text class="sub-desc">{{ card.desc }}</text>
|
||||
<view class="sub-go">
|
||||
<text class="sub-go-text">进入</text>
|
||||
<FaIcon name="arrow-right" color="#ffffff" :size="11" />
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
@@ -53,6 +49,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
|
||||
const headPad = mpHeadPad()
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
import { fetchHome } from '@/api/workspace.js'
|
||||
@@ -186,6 +185,10 @@ onShow(() => {
|
||||
.head {
|
||||
flex-shrink: 0;
|
||||
padding: calc(var(--status-bar-height, 44px) + 32rpx) $page-padding-x 12rpx;
|
||||
/* #ifdef MP-WEIXIN */
|
||||
/* 小程序端 --status-bar-height 固定 25px,需真实状态栏(env)+胶囊避让 */
|
||||
padding-top: calc(env(safe-area-inset-top, 25px) + 96rpx);
|
||||
/* #endif */
|
||||
background: $color-bg-page;
|
||||
}
|
||||
|
||||
@@ -275,92 +278,54 @@ onShow(() => {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ============ 子系统宫格 ============
|
||||
用 flex 换行而不是 grid:小程序端 wxss 对 grid 的支持不稳定,
|
||||
用 flex-wrap + 百分比宽度才能保证任何屏宽都稳定两列,
|
||||
且同一行卡片高度自动拉齐(align-items 默认 stretch)。 */
|
||||
.card-grid {
|
||||
/* ============ 子系统门户宫格:一行 2 个,仅图标 + 名称 ============ */
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
margin-right: -20rpx;
|
||||
padding-top: 16rpx;
|
||||
}
|
||||
|
||||
.sub-card {
|
||||
@include card;
|
||||
width: calc(50% - 10rpx);
|
||||
margin-right: 20rpx;
|
||||
.cell {
|
||||
width: 50%;
|
||||
padding-right: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 26rpx 22rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
@include card;
|
||||
padding: 40rpx 16rpx 34rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.15s ease, box-shadow 0.2s ease;
|
||||
|
||||
/* 每行第 2 个不右边距,保证 50%-10rpx + 20rpx + 50%-10rpx = 100% */
|
||||
&:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* 未开通:靠灰色图标与灰色文字表达,不使用角标 */
|
||||
&.locked {
|
||||
.sub-name,
|
||||
.sub-desc {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.sub-go {
|
||||
background: $color-border;
|
||||
}
|
||||
|
||||
.sub-go-text {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sub-icon {
|
||||
@include tint-icon(88rpx, 24rpx);
|
||||
.portal-icon {
|
||||
@include tint-icon(108rpx, 28rpx);
|
||||
}
|
||||
|
||||
.sub-name {
|
||||
margin-top: 20rpx;
|
||||
font-size: 31rpx;
|
||||
.portal-name {
|
||||
max-width: 100%;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5rpx;
|
||||
color: $color-text;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-desc {
|
||||
margin-top: 8rpx;
|
||||
/* 按钮用 margin-top:auto 吸底,这里保证内容短时也有最小间距 */
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 22rpx;
|
||||
/* 未开通:灰底图标 + 灰字,不做角标,保持门户的干净 */
|
||||
.portal-card.locked .portal-name {
|
||||
color: $color-text-muted;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 进入按钮:吸底 + 撑满宽度,同一行的卡片按钮位置自动对齐 */
|
||||
.sub-go {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
height: 58rpx;
|
||||
margin-top: auto;
|
||||
border-radius: $radius-md;
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
.sub-go-text {
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<scroll-view class="body" scroll-y :show-scrollbar="false">
|
||||
<view class="body-inner">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="!payroll"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
text="工资单不存在或无权查看"
|
||||
/>
|
||||
<block v-else>
|
||||
<view class="hero-card">
|
||||
<view class="hero-head">
|
||||
<text class="hero-month">{{ payroll.payroll_month }} 工资单</text>
|
||||
<u-tag
|
||||
:text="getPayrollStatusLabel(payroll.status)"
|
||||
size="mini"
|
||||
bg-color="rgba(255,255,255,0.22)"
|
||||
color="#ffffff"
|
||||
border-color="rgba(255,255,255,0.22)"
|
||||
/>
|
||||
</view>
|
||||
<view class="hero-amount">
|
||||
<text class="amount-label">实发合计</text>
|
||||
<text class="amount-value">¥{{ money(payroll.net_salary) }}</text>
|
||||
</view>
|
||||
<view class="hero-meta">
|
||||
<text>{{ payroll.employee_name || '本人' }}</text>
|
||||
<text v-if="payroll.department">· {{ payroll.department }}</text>
|
||||
<text v-if="payroll.position">· {{ payroll.position }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="sum-card">
|
||||
<view class="sum-item">
|
||||
<text class="sum-value income">¥{{ money(payroll.gross_salary) }}</text>
|
||||
<text class="sum-label">应发合计</text>
|
||||
</view>
|
||||
<view class="sum-line" />
|
||||
<view class="sum-item">
|
||||
<text class="sum-value deduct">¥{{ money(payroll.total_deduction) }}</text>
|
||||
<text class="sum-label">扣款合计</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="incomeItems.length" class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">应发项目</text>
|
||||
</view>
|
||||
<view v-for="item in incomeItems" :key="item.key" class="row">
|
||||
<text class="row-label">{{ item.label }}</text>
|
||||
<text class="row-value income">+{{ money(item.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="deductItems.length" class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar danger" />
|
||||
<text class="group-title">扣款项目</text>
|
||||
</view>
|
||||
<view v-for="item in deductItems" :key="item.key" class="row">
|
||||
<text class="row-label">{{ item.label }}</text>
|
||||
<text class="row-value deduct">-{{ money(item.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">其他信息</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">薪资月份</text>
|
||||
<text class="row-text">{{ payroll.payroll_month || '-' }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">状态</text>
|
||||
<text class="row-text">{{ getPayrollStatusLabel(payroll.status) }}</text>
|
||||
</view>
|
||||
<view v-if="payroll.confirmed_at" class="row">
|
||||
<text class="row-label">确认时间</text>
|
||||
<text class="row-text">{{ payroll.confirmed_at }}</text>
|
||||
</view>
|
||||
<view v-if="payroll.paid_at" class="row">
|
||||
<text class="row-label">发放时间</text>
|
||||
<text class="row-text">{{ payroll.paid_at }}</text>
|
||||
</view>
|
||||
<view v-if="payroll.remark" class="row">
|
||||
<text class="row-label">备注</text>
|
||||
<text class="row-text">{{ payroll.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getPayroll, getPayrollStatusLabel } from '@/api/oa.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const id = ref(0)
|
||||
const payroll = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const INCOME_FIELDS = [
|
||||
{ key: 'base_salary', label: '基本工资' },
|
||||
{ key: 'post_allowance', label: '岗位津贴' },
|
||||
{ key: 'performance_salary', label: '绩效工资' },
|
||||
{ key: 'transport_allowance', label: '交通补贴' },
|
||||
{ key: 'meal_allowance', label: '餐补' },
|
||||
{ key: 'communication_allowance', label: '通讯补贴' },
|
||||
{ key: 'overtime_pay', label: '加班费' },
|
||||
{ key: 'bonus', label: '奖金' },
|
||||
{ key: 'other_addition', label: '其他增加' }
|
||||
]
|
||||
|
||||
const DEDUCT_FIELDS = [
|
||||
{ key: 'leave_deduction', label: '请假扣款' },
|
||||
{ key: 'late_deduction', label: '迟到扣款' },
|
||||
{ key: 'other_deduction', label: '其他扣款' },
|
||||
{ key: 'social_insurance', label: '社保' },
|
||||
{ key: 'housing_fund', label: '公积金' },
|
||||
{ key: 'personal_income_tax', label: '个人所得税' }
|
||||
]
|
||||
|
||||
const incomeItems = computed(() => {
|
||||
if (!payroll.value) return []
|
||||
const base = INCOME_FIELDS
|
||||
.filter(f => Number(payroll.value[f.key]) > 0)
|
||||
.map(f => ({ key: f.key, label: f.label, amount: Number(payroll.value[f.key]) }))
|
||||
const custom = (payroll.value.items || [])
|
||||
.filter(it => it.item_type === 1)
|
||||
.map(it => ({ key: 'c' + it.id, label: it.item_name, amount: Number(it.amount) }))
|
||||
return base.concat(custom)
|
||||
})
|
||||
|
||||
const deductItems = computed(() => {
|
||||
if (!payroll.value) return []
|
||||
const base = DEDUCT_FIELDS
|
||||
.filter(f => Number(payroll.value[f.key]) > 0)
|
||||
.map(f => ({ key: f.key, label: f.label, amount: Number(payroll.value[f.key]) }))
|
||||
const custom = (payroll.value.items || [])
|
||||
.filter(it => it.item_type === 2)
|
||||
.map(it => ({ key: 'c' + it.id, label: it.item_name, amount: Number(it.amount) }))
|
||||
return base.concat(custom)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
id.value = Number((current && current.options && current.options.id) || 0)
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
if (!id.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
payroll.value = await getPayroll(id.value)
|
||||
} catch (e) {
|
||||
payroll.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
return (Number(value) || 0).toFixed(2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.body-inner {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
@include card;
|
||||
padding: 32rpx 28rpx;
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
||||
box-shadow: $shadow-float;
|
||||
}
|
||||
|
||||
.hero-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero-month {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.hero-amount {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 60rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
margin-top: 16rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.sum-card {
|
||||
@include card;
|
||||
margin-top: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 26rpx 0;
|
||||
}
|
||||
|
||||
.sum-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.sum-line {
|
||||
width: 1rpx;
|
||||
height: 56rpx;
|
||||
background: $color-divider;
|
||||
}
|
||||
|
||||
.sum-value {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sum-label {
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
@include card;
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.group-bar {
|
||||
width: 6rpx;
|
||||
height: 26rpx;
|
||||
border-radius: 3rpx;
|
||||
background: $color-primary;
|
||||
|
||||
&.danger {
|
||||
background: $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 29rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-size: 27rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
font-size: 29rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-text {
|
||||
font-size: 27rpx;
|
||||
color: $color-text;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.income {
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.deduct {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 200rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="toolbar">
|
||||
<picker mode="date" fields="month" :value="month || currentMonth" @change="onMonthChange">
|
||||
<view class="month-picker">
|
||||
<FaIcon name="calendar-days" color="#2563eb" :size="16" />
|
||||
<text class="month-text">{{ month || '全部月份' }}</text>
|
||||
<FaIcon name="chevron-right" color="#8a94ad" :size="12" />
|
||||
</view>
|
||||
</picker>
|
||||
<view v-if="month" class="clear-btn" @tap="clearMonth">查看全部</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="list.length === 0"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
text="暂无工资单记录"
|
||||
/>
|
||||
<view v-else class="pay-list">
|
||||
<view v-for="item in list" :key="item.id" class="pay-card" @tap="openDetail(item.id)">
|
||||
<view class="pay-head">
|
||||
<text class="pay-month">{{ item.payroll_month }}</text>
|
||||
<text class="status-tag" :class="'st-' + item.status">{{ getPayrollStatusLabel(item.status) }}</text>
|
||||
</view>
|
||||
<view class="pay-amount">
|
||||
<text class="amount-label">实发</text>
|
||||
<text class="amount-value">¥{{ money(item.net_salary) }}</text>
|
||||
</view>
|
||||
<view class="pay-meta">
|
||||
<text>{{ item.employee_name || '本人' }}</text>
|
||||
<text v-if="item.department">· {{ item.department }}</text>
|
||||
<text v-if="item.position">· {{ item.position }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchPayrolls, getPayrollStatusLabel } from '@/api/oa.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
import { pad } from '@/utils/date.js'
|
||||
|
||||
const month = ref('')
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadPayrolls()
|
||||
})
|
||||
|
||||
async function loadPayrolls() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchPayrolls({ payrollMonth: month.value, pageSize: 50 })
|
||||
list.value = res.list
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onMonthChange(e) {
|
||||
month.value = e.detail.value
|
||||
loadPayrolls()
|
||||
}
|
||||
|
||||
function clearMonth() {
|
||||
month.value = ''
|
||||
loadPayrolls()
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
return (Number(value) || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
uni.navigateTo({ url: `/pages/oa/compensation/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx $page-padding-x;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.month-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
.month-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
font-size: 26rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.pay-list {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.pay-card {
|
||||
@include card;
|
||||
padding: 26rpx 24rpx;
|
||||
margin-bottom: 18rpx;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pay-month {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
padding: 2rpx 14rpx;
|
||||
font-size: 21rpx;
|
||||
border-radius: $radius-sm;
|
||||
color: $color-text-muted;
|
||||
background: $color-bg-page;
|
||||
|
||||
&.st-1 {
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
}
|
||||
|
||||
&.st-2 {
|
||||
color: $color-success;
|
||||
background: #eafaf1;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 44rpx;
|
||||
font-weight: 600;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.pay-meta {
|
||||
margin-top: 12rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<scroll-view class="body" scroll-y :show-scrollbar="false">
|
||||
<view class="body-inner">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!contact" class="state-box">
|
||||
<FaIcon name="circle-exclamation" color="#c0c4cc" :size="40" />
|
||||
<text class="state-text">联系人不存在或无权查看</text>
|
||||
</view>
|
||||
<block v-else>
|
||||
<view class="hero-card">
|
||||
<view class="hero-avatar">
|
||||
<image v-if="contact.avatar" class="hero-avatar-img" :src="contact.avatar" mode="aspectFill" />
|
||||
<text v-else class="hero-avatar-text">{{ firstChar(contact.contact_name) }}</text>
|
||||
</view>
|
||||
<view class="hero-info">
|
||||
<view class="hero-name-row">
|
||||
<text class="hero-name">{{ contact.contact_name }}</text>
|
||||
<text class="type-tag" :class="contact.contact_type === 1 ? 'inner' : 'outer'">
|
||||
{{ getContactTypeLabel(contact.contact_type) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="hero-sub">{{ heroSub }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="mobilesText || contact.work_phone || contact.email" class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">联系方式</text>
|
||||
</view>
|
||||
<view v-if="mobilesText" class="row">
|
||||
<text class="row-label">手机号</text>
|
||||
<text class="row-text">{{ mobilesText }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(mobilesText)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="contact.work_phone" class="row">
|
||||
<text class="row-label">工作电话</text>
|
||||
<text class="row-text">{{ contact.work_phone }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(contact.work_phone)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="contact.email" class="row">
|
||||
<text class="row-label">邮箱</text>
|
||||
<text class="row-text">{{ contact.email }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(contact.email)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">更多信息</text>
|
||||
</view>
|
||||
<view v-if="contact.company_name" class="row">
|
||||
<text class="row-label">公司</text>
|
||||
<text class="row-text">{{ contact.company_name }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(contact.company_name)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="deptText" class="row">
|
||||
<text class="row-label">部门</text>
|
||||
<text class="row-text">{{ deptText }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(deptText)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="contact.position_title" class="row">
|
||||
<text class="row-label">职位</text>
|
||||
<text class="row-text">{{ contact.position_title }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(contact.position_title)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="contact.wechat" class="row">
|
||||
<text class="row-label">微信</text>
|
||||
<text class="row-text">{{ contact.wechat }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(contact.wechat)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="contact.qq" class="row">
|
||||
<text class="row-label">QQ</text>
|
||||
<text class="row-text">{{ contact.qq }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(contact.qq)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="contact.address" class="row">
|
||||
<text class="row-label">地址</text>
|
||||
<text class="row-text">{{ contact.address }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(contact.address)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="contact.remark" class="row">
|
||||
<text class="row-label">备注</text>
|
||||
<text class="row-text">{{ contact.remark }}</text>
|
||||
<view class="copy-btn" @tap.stop="copy(contact.remark)">
|
||||
<FaIcon name="copy" color="#2563eb" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="contact && callPhone" class="action-bar">
|
||||
<view class="call-action" @tap="call">
|
||||
<FaIcon name="phone" color="#ffffff" :size="16" />
|
||||
<text>拨打电话</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getContact, getContactTypeLabel, formatMobiles, primaryPhone } from '@/api/contact.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const id = ref(0)
|
||||
const contact = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const mobilesText = computed(() => {
|
||||
if (!contact.value) return ''
|
||||
const text = formatMobiles(contact.value.mobiles)
|
||||
if (text) return text
|
||||
return String(contact.value.phone || '').trim()
|
||||
})
|
||||
|
||||
const deptText = computed(() => {
|
||||
if (!contact.value) return ''
|
||||
return contact.value.org_name || contact.value.dept_name || ''
|
||||
})
|
||||
|
||||
const heroSub = computed(() => {
|
||||
const parts = []
|
||||
if (contact.value && contact.value.position_title) parts.push(contact.value.position_title)
|
||||
if (deptText.value) parts.push(deptText.value)
|
||||
return parts.length ? parts.join(' · ') : '公司内部通讯录'
|
||||
})
|
||||
|
||||
const callPhone = computed(() => (contact.value ? primaryPhone(contact.value) : ''))
|
||||
|
||||
onLoad((options) => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
id.value = Number((options && options.id) || 0)
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
if (!id.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
contact.value = await getContact(id.value)
|
||||
} catch (e) {
|
||||
contact.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function firstChar(name) {
|
||||
return String(name || '?').charAt(0)
|
||||
}
|
||||
|
||||
function call() {
|
||||
if (!callPhone.value) return
|
||||
uni.makePhoneCall({ phoneNumber: callPhone.value })
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
const data = String(text || '').trim()
|
||||
if (!data) return
|
||||
uni.setClipboardData({
|
||||
data,
|
||||
success() {
|
||||
uni.showToast({ title: '已复制', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.body-inner {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 36rpx 30rpx;
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
||||
box-shadow: $shadow-float;
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero-avatar-text {
|
||||
font-size: 48rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 2rpx 14rpx;
|
||||
font-size: 21rpx;
|
||||
border-radius: $radius-sm;
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 25rpx;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
@include card;
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.group-bar {
|
||||
width: 6rpx;
|
||||
height: 26rpx;
|
||||
border-radius: 3rpx;
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 29rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
min-width: 0;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 27rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 27rpx;
|
||||
color: $color-text;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: $radius-sm;
|
||||
background: $color-primary-bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
flex-shrink: 0;
|
||||
padding: 20rpx $page-padding-x calc(20rpx + env(safe-area-inset-bottom, 0px));
|
||||
background: $color-card;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.call-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
height: 88rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-primary;
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&:active {
|
||||
background: $color-primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="toolbar">
|
||||
<u-search
|
||||
v-model="keyword"
|
||||
placeholder="搜索姓名、手机号、邮箱"
|
||||
:show-action="false"
|
||||
shape="round"
|
||||
bg-color="#f1f5f9"
|
||||
color="#0f172a"
|
||||
@search="onSearch"
|
||||
@clear="clearSearch"
|
||||
/>
|
||||
<u-tabs
|
||||
:list="TYPE_TABS"
|
||||
:current="typeIndex"
|
||||
:scrollable="false"
|
||||
line-color="#2563eb"
|
||||
:line-width="40"
|
||||
:line-height="6"
|
||||
:active-style="{ color: '#2563eb', fontWeight: '600' }"
|
||||
:inactive-style="{ color: '#475569' }"
|
||||
:item-style="{ height: '84rpx' }"
|
||||
@change="onTabChange"
|
||||
/>
|
||||
<u-dropdown active-color="#2563eb" :height="84">
|
||||
<u-dropdown-item
|
||||
v-model="selectedOrgId"
|
||||
title="部门"
|
||||
:options="deptOptions"
|
||||
height="520rpx"
|
||||
@change="onDeptChange"
|
||||
/>
|
||||
</u-dropdown>
|
||||
</view>
|
||||
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="list.length === 0"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
:text="keyword ? '未找到相关联系人' : '暂无联系人'"
|
||||
/>
|
||||
<block v-else>
|
||||
<view class="contact-list">
|
||||
<view v-for="item in list" :key="item.id" class="contact-card" @tap="openDetail(item.id)">
|
||||
<u-avatar
|
||||
:src="item.avatar"
|
||||
:text="firstChar(item.contact_name)"
|
||||
:size="48"
|
||||
bg-color="#2563eb"
|
||||
font-size="18"
|
||||
/>
|
||||
<view class="contact-body">
|
||||
<view class="contact-head">
|
||||
<text class="contact-name">{{ item.contact_name }}</text>
|
||||
<text class="type-tag" :class="item.contact_type === 1 ? 'inner' : 'outer'">
|
||||
{{ getContactTypeLabel(item.contact_type) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="contact-sub">{{ subLine(item) }}</text>
|
||||
<text v-if="primaryPhone(item)" class="contact-phone">{{ primaryPhone(item) }}</text>
|
||||
</view>
|
||||
<view v-if="primaryPhone(item)" class="call-btn" @tap.stop="call(item)">
|
||||
<u-icon name="phone-fill" color="#ffffff" size="16" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hasMore" class="more-tip" @tap="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
<view v-else class="more-tip end">
|
||||
<text>已加载全部 {{ total }} 位联系人</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
fetchContacts,
|
||||
fetchContactOrgTree,
|
||||
getContactTypeLabel,
|
||||
primaryPhone
|
||||
} from '@/api/contact.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
/** 顶部大类 tab:全部 / 内部 / 外部,索引与 contact_type 对应(0 表示不筛选) */
|
||||
const TYPE_TABS = [{ name: '全部' }, { name: '内部' }, { name: '外部' }]
|
||||
const TYPE_VALUES = [0, 1, 2]
|
||||
|
||||
const keyword = ref('')
|
||||
const orgs = ref([])
|
||||
const selectedOrgId = ref(0)
|
||||
const typeIndex = ref(0)
|
||||
const contactType = ref(0)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadOrgTree()
|
||||
loadContacts(true)
|
||||
})
|
||||
|
||||
/** 部门下拉选项:全部 + 有内部联系人的部门(带人数) */
|
||||
const deptOptions = computed(() => {
|
||||
const options = [{ label: '全部部门', value: 0 }]
|
||||
orgs.value.forEach((org) => {
|
||||
const count = org.contact_count ? `(${org.contact_count})` : ''
|
||||
options.push({ label: `${org.org_name}${count}`, value: org.id })
|
||||
})
|
||||
return options
|
||||
})
|
||||
|
||||
/** 组织树拍平为部门列表,只保留有内部联系人的部门 */
|
||||
function flattenOrgs(nodes, out) {
|
||||
;(nodes || []).forEach((n) => {
|
||||
if (Number(n.contact_count) > 0) {
|
||||
out.push({ id: n.id, org_name: n.org_name, contact_count: n.contact_count })
|
||||
}
|
||||
if (n.children && n.children.length) {
|
||||
flattenOrgs(n.children, out)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
async function loadOrgTree() {
|
||||
try {
|
||||
const tree = await fetchContactOrgTree()
|
||||
orgs.value = flattenOrgs(Array.isArray(tree) ? tree : [], [])
|
||||
} catch (e) {
|
||||
orgs.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContacts(reset) {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
loading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const res = await fetchContacts({
|
||||
keyword: keyword.value,
|
||||
orgId: selectedOrgId.value,
|
||||
contactType: contactType.value,
|
||||
page: page.value,
|
||||
pageSize
|
||||
})
|
||||
list.value = reset ? res.list : list.value.concat(res.list)
|
||||
total.value = res.total
|
||||
hasMore.value = list.value.length < res.total
|
||||
} catch (e) {
|
||||
if (reset) list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore.value || loading.value || loadingMore.value) return
|
||||
page.value += 1
|
||||
loadContacts(false)
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
loadContacts(true)
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
keyword.value = ''
|
||||
loadContacts(true)
|
||||
}
|
||||
|
||||
function onTabChange(item, index) {
|
||||
typeIndex.value = index
|
||||
contactType.value = TYPE_VALUES[index] || 0
|
||||
loadContacts(true)
|
||||
}
|
||||
|
||||
function onDeptChange() {
|
||||
loadContacts(true)
|
||||
}
|
||||
|
||||
function subLine(item) {
|
||||
const parts = []
|
||||
if (item.position_title) parts.push(item.position_title)
|
||||
const dept = item.org_name || item.dept_name
|
||||
if (dept) parts.push(dept)
|
||||
return parts.length ? parts.join(' · ') : '暂无职位信息'
|
||||
}
|
||||
|
||||
function firstChar(name) {
|
||||
return String(name || '?').charAt(0)
|
||||
}
|
||||
|
||||
function call(item) {
|
||||
const phoneNumber = primaryPhone(item)
|
||||
if (!phoneNumber) return
|
||||
uni.makePhoneCall({ phoneNumber })
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
uni.navigateTo({ url: `/pages/oa/contact/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
padding: 20rpx $page-padding-x 0;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 18rpx;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
max-width: 60%;
|
||||
font-size: 31rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 2rpx 12rpx;
|
||||
font-size: 20rpx;
|
||||
border-radius: $radius-sm;
|
||||
color: $color-text-muted;
|
||||
background: $color-bg-page;
|
||||
|
||||
&.inner {
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-phone {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.call-btn {
|
||||
flex-shrink: 0;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 6rpx 16rpx rgba(37, 99, 235, 0.28);
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.more-tip {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
|
||||
&.end {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<scroll-view class="body" scroll-y :show-scrollbar="false">
|
||||
<view class="body-inner">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="!doc"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
text="文档不存在或无访问权限"
|
||||
/>
|
||||
<block v-else>
|
||||
<view class="head-card">
|
||||
<view class="doc-icon">
|
||||
<FaIcon :name="getDocTypeMeta(doc.doc_type).icon" color="#ffffff" :size="26" />
|
||||
</view>
|
||||
<view class="head-body">
|
||||
<text class="title">{{ doc.title }}</text>
|
||||
<view class="head-meta">
|
||||
<text>{{ getDocTypeMeta(doc.doc_type).label }}</text>
|
||||
<text v-if="doc.ext">· {{ doc.ext.toUpperCase() }}</text>
|
||||
<text v-if="formatFileSize(doc.size)">· {{ formatFileSize(doc.size) }}</text>
|
||||
<text v-if="doc.is_online">· 在线文档</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-card">
|
||||
<view class="action-btn-wrap">
|
||||
<u-button
|
||||
type="primary"
|
||||
color="#2563eb"
|
||||
icon="download"
|
||||
text="下载"
|
||||
:disabled="!doc.file_url"
|
||||
@click="onDownload"
|
||||
/>
|
||||
</view>
|
||||
<view class="action-btn-wrap">
|
||||
<u-button
|
||||
:type="doc.is_star === 1 ? 'warning' : 'info'"
|
||||
:plain="doc.is_star !== 1"
|
||||
:icon="doc.is_star === 1 ? 'star-fill' : 'star'"
|
||||
:text="doc.is_star === 1 ? '已收藏' : '收藏'"
|
||||
@click="onStar"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="info-card">
|
||||
<view class="info-row">
|
||||
<text class="info-label">所属分类</text>
|
||||
<text class="info-value">{{ doc.visibility === 1 ? '私密文档' : '共享文档' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">创建人</text>
|
||||
<text class="info-value">{{ doc.creator_name || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">版本</text>
|
||||
<text class="info-value">V{{ doc.version || 1 }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">创建时间</text>
|
||||
<text class="info-value">{{ doc.create_time || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">更新时间</text>
|
||||
<text class="info-value">{{ doc.update_time || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">浏览 / 下载</text>
|
||||
<text class="info-value">{{ doc.view_count || 0 }} / {{ doc.download_count || 0 }}</text>
|
||||
</view>
|
||||
<view v-if="doc.tags" class="info-row">
|
||||
<text class="info-label">标签</text>
|
||||
<text class="info-value">{{ doc.tags }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="doc.summary" class="summary-card">
|
||||
<text class="section-title">文档摘要</text>
|
||||
<text class="summary">{{ doc.summary }}</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getDoc, downloadDoc, toggleDocStar, getDocTypeMeta, formatFileSize } from '@/api/oa.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
import { getBaseURL } from '@/api/request.js'
|
||||
|
||||
const id = ref(0)
|
||||
const doc = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
id.value = Number((current && current.options && current.options.id) || 0)
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
if (!id.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
doc.value = await getDoc(id.value)
|
||||
} catch (e) {
|
||||
doc.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把可能的相对地址补全为绝对地址 */
|
||||
function absoluteUrl(url) {
|
||||
const value = String(url || '').trim()
|
||||
if (!value) return ''
|
||||
if (/^https?:/i.test(value)) return value
|
||||
const base = String(getBaseURL() || '').replace(/\/$/, '')
|
||||
if (value.startsWith('/')) return base + value
|
||||
return base + '/' + value
|
||||
}
|
||||
|
||||
async function onDownload() {
|
||||
if (!doc.value) return
|
||||
if (!doc.value.file_url) {
|
||||
uni.showToast({ title: '该文档暂无可下载文件', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await downloadDoc(id.value)
|
||||
if (doc.value) doc.value.download_count = (res && res.download_count) || doc.value.download_count
|
||||
const url = absoluteUrl((res && res.file_url) || doc.value.file_url)
|
||||
if (!url) {
|
||||
uni.showToast({ title: '下载地址无效', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.showLoading({ title: '下载中...' })
|
||||
uni.downloadFile({
|
||||
url,
|
||||
success(r) {
|
||||
uni.hideLoading()
|
||||
if (r.statusCode !== 200) {
|
||||
copyLink(url)
|
||||
return
|
||||
}
|
||||
uni.openDocument({
|
||||
filePath: r.tempFilePath,
|
||||
showMenu: true,
|
||||
fail() {
|
||||
copyLink(url)
|
||||
}
|
||||
})
|
||||
},
|
||||
fail() {
|
||||
uni.hideLoading()
|
||||
copyLink(url)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink(url) {
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success() {
|
||||
uni.showToast({ title: '已复制下载链接,可在浏览器打开', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onStar() {
|
||||
if (!doc.value) return
|
||||
try {
|
||||
const res = await toggleDocStar(id.value)
|
||||
doc.value.is_star = res && res.is_star
|
||||
uni.showToast({ title: doc.value.is_star === 1 ? '已收藏' : '已取消收藏', icon: 'none' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.body-inner {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.head-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.head-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: $color-text;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.head-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6rpx;
|
||||
margin-top: 12rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn-wrap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
@include card;
|
||||
margin-top: 20rpx;
|
||||
padding: 8rpx 28rpx;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
padding: 22rpx 0;
|
||||
|
||||
& + & {
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
@include card;
|
||||
margin-top: 20rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 27rpx;
|
||||
line-height: 1.7;
|
||||
color: $color-text-secondary;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 200rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="toolbar">
|
||||
<u-tabs
|
||||
:list="spaceTabs"
|
||||
:current="spaceIndex"
|
||||
:scrollable="false"
|
||||
line-color="#2563eb"
|
||||
:line-width="40"
|
||||
:active-style="{ color: '#2563eb', fontWeight: '600' }"
|
||||
:inactive-style="{ color: '#475569' }"
|
||||
:item-style="{ height: '84rpx' }"
|
||||
@change="onSpaceTabChange"
|
||||
/>
|
||||
|
||||
<u-search
|
||||
v-model="keyword"
|
||||
placeholder="搜索文档名称、标签"
|
||||
:show-action="false"
|
||||
shape="round"
|
||||
bg-color="#f1f5f9"
|
||||
color="#0f172a"
|
||||
custom-style="margin-top:12rpx;"
|
||||
@search="loadDocs"
|
||||
@clear="clearSearch"
|
||||
/>
|
||||
|
||||
<scroll-view class="cat-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="cat-row">
|
||||
<view class="cat-chip" :class="{ active: categoryId === 0 && !uncategorized }" @tap="selectCategory(0)">
|
||||
全部
|
||||
</view>
|
||||
<view class="cat-chip" :class="{ active: uncategorized }" @tap="selectUncategorized">未分类</view>
|
||||
<view
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
class="cat-chip"
|
||||
:class="{ active: categoryId === cat.id }"
|
||||
@tap="selectCategory(cat.id)"
|
||||
>{{ cat.name }}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="list.length === 0"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
text="该分类下暂无文档"
|
||||
/>
|
||||
<view v-else class="doc-list">
|
||||
<view v-for="item in list" :key="item.id" class="doc-card" @tap="openDetail(item.id)">
|
||||
<view class="doc-icon">
|
||||
<FaIcon :name="getDocTypeMeta(item.doc_type).icon" color="#ffffff" :size="20" />
|
||||
</view>
|
||||
<view class="doc-body">
|
||||
<view class="doc-head">
|
||||
<text class="doc-title">{{ item.title }}</text>
|
||||
<FaIcon v-if="item.is_star === 1" name="star" color="#f7ba2a" :size="14" />
|
||||
</view>
|
||||
<view class="doc-meta">
|
||||
<text>{{ getDocTypeMeta(item.doc_type).label }}</text>
|
||||
<text v-if="item.ext">· {{ item.ext.toUpperCase() }}</text>
|
||||
<text v-if="formatFileSize(item.size)">· {{ formatFileSize(item.size) }}</text>
|
||||
<text v-if="item.is_online">· 在线文档</text>
|
||||
</view>
|
||||
<view class="doc-meta">
|
||||
<text>{{ item.creator_name || item.owner_name || '未知' }}</text>
|
||||
<text>· {{ item.update_time || item.create_time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchDocCategories, fetchDocs, getDocTypeMeta, formatFileSize } from '@/api/oa.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const spaces = [
|
||||
{ value: 'shared', label: '共享文档' },
|
||||
{ value: 'personal', label: '私密文档' }
|
||||
]
|
||||
const spaceTabs = spaces.map((s) => ({ name: s.label }))
|
||||
|
||||
const scope = ref('shared')
|
||||
const spaceIndex = computed(() => {
|
||||
const i = spaces.findIndex((s) => s.value === scope.value)
|
||||
return i < 0 ? 0 : i
|
||||
})
|
||||
const keyword = ref('')
|
||||
const categoryId = ref(0)
|
||||
const uncategorized = ref(false)
|
||||
const categories = ref([])
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadCategories()
|
||||
loadDocs()
|
||||
})
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const tree = await fetchDocCategories(scope.value)
|
||||
categories.value = Array.isArray(tree) ? tree : []
|
||||
} catch (e) {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchDocs({
|
||||
scope: scope.value,
|
||||
keyword: keyword.value,
|
||||
categoryId: categoryId.value,
|
||||
uncategorized: uncategorized.value,
|
||||
pageSize: 50
|
||||
})
|
||||
list.value = res.list
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeScope(value) {
|
||||
if (scope.value === value) return
|
||||
scope.value = value
|
||||
categoryId.value = 0
|
||||
uncategorized.value = false
|
||||
loadCategories()
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
function onSpaceTabChange(item, index) {
|
||||
const tab = spaces[index]
|
||||
if (tab) changeScope(tab.value)
|
||||
}
|
||||
|
||||
function selectCategory(id) {
|
||||
categoryId.value = id
|
||||
uncategorized.value = false
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
function selectUncategorized() {
|
||||
categoryId.value = 0
|
||||
uncategorized.value = true
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
keyword.value = ''
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
uni.navigateTo({ url: `/pages/oa/document/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 20rpx $page-padding-x 0;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.cat-scroll {
|
||||
white-space: nowrap;
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
|
||||
.cat-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.cat-chip {
|
||||
flex-shrink: 0;
|
||||
padding: 10rpx 26rpx;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-secondary;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
|
||||
&.active {
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.doc-list {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 18rpx;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6rpx;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,481 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<scroll-view class="body" scroll-y :show-scrollbar="false">
|
||||
<view class="body-inner">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="!file"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
text="您暂无员工档案"
|
||||
/>
|
||||
<block v-else>
|
||||
<view class="hero-card">
|
||||
<view class="hero-avatar">
|
||||
<text>{{ avatarText }}</text>
|
||||
</view>
|
||||
<view class="hero-info">
|
||||
<view class="hero-name-row">
|
||||
<text class="hero-name">{{ file.employee_name || '本人' }}</text>
|
||||
<u-tag
|
||||
:text="getEmploymentStatusLabel(file.employment_status)"
|
||||
size="mini"
|
||||
bg-color="rgba(255,255,255,0.22)"
|
||||
color="#ffffff"
|
||||
border-color="rgba(255,255,255,0.22)"
|
||||
class="status-tag"
|
||||
/>
|
||||
</view>
|
||||
<text class="hero-sub">{{ positionText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">档案完整度</text>
|
||||
<text class="group-extra">{{ file.completeness || 0 }}%</text>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: (file.completeness || 0) + '%' }" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">基本信息</text>
|
||||
</view>
|
||||
<view v-for="row in baseInfo" :key="row.label" class="row">
|
||||
<text class="row-label">{{ row.label }}</text>
|
||||
<text class="row-text">{{ row.value || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">联系方式</text>
|
||||
</view>
|
||||
<view v-for="row in contactInfo" :key="row.label" class="row">
|
||||
<text class="row-label">{{ row.label }}</text>
|
||||
<text class="row-text">{{ row.value || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">紧急联系人</text>
|
||||
</view>
|
||||
<view v-for="row in emergencyInfo" :key="row.label" class="row">
|
||||
<text class="row-label">{{ row.label }}</text>
|
||||
<text class="row-text">{{ row.value || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-for="group in recordGroups" :key="group.type" class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">{{ group.label }}</text>
|
||||
<text class="group-extra">{{ group.list.length }}</text>
|
||||
</view>
|
||||
<view v-if="group.list.length === 0" class="record-empty">
|
||||
<text class="record-empty-text">暂无记录</text>
|
||||
</view>
|
||||
<view v-else v-for="record in group.list" :key="record.id" class="record-item">
|
||||
<view class="record-top">
|
||||
<text class="record-title">{{ record.title || group.label }}</text>
|
||||
<text v-if="record.sub_title" class="record-sub">{{ record.sub_title }}</text>
|
||||
</view>
|
||||
<view v-if="recordDate(record)" class="record-date">
|
||||
<text>{{ recordDate(record) }}</text>
|
||||
</view>
|
||||
<text v-if="record.extra" class="record-extra">{{ record.extra }}</text>
|
||||
<text v-if="record.description" class="record-desc">{{ record.description }}</text>
|
||||
<view v-if="record.attachment_url" class="record-file" @tap="openAttachment(record.attachment_url)">
|
||||
<FaIcon name="paperclip" color="#2563eb" :size="13" />
|
||||
<text>查看附件</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
getMyEmployeeFile,
|
||||
getEmploymentStatusLabel,
|
||||
getMaritalStatusLabel,
|
||||
FILE_RECORD_TYPE_OPTIONS
|
||||
} from '@/api/oa.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
import { getBaseURL } from '@/api/request.js'
|
||||
|
||||
const GENDER_TEXT = { 0: '未知', 1: '男', 2: '女' }
|
||||
|
||||
const file = ref(null)
|
||||
const records = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const avatarText = computed(() => {
|
||||
const name = (file.value && file.value.employee_name) || '我'
|
||||
return name.slice(0, 1)
|
||||
})
|
||||
|
||||
const positionText = computed(() => {
|
||||
if (!file.value) return ''
|
||||
const parts = [file.value.department_name || file.value.department, file.value.position].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
const baseInfo = computed(() => {
|
||||
const f = file.value || {}
|
||||
return [
|
||||
{ label: '档案编号', value: f.file_no },
|
||||
{ label: '员工账号', value: f.employee_account },
|
||||
{ label: '性别', value: GENDER_TEXT[Number(f.gender)] || '未知' },
|
||||
{ label: '生日', value: f.birthday },
|
||||
{ label: '身份证号', value: f.id_card },
|
||||
{ label: '学历', value: f.education },
|
||||
{ label: '民族', value: f.nation },
|
||||
{ label: '政治面貌', value: f.political_status },
|
||||
{ label: '婚姻状况', value: getMaritalStatusLabel(f.marital_status) },
|
||||
{ label: '籍贯', value: f.native_place },
|
||||
{ label: '所属部门', value: f.department_name || f.department },
|
||||
{ label: '职位', value: f.position },
|
||||
{ label: '入职日期', value: f.hire_date },
|
||||
{ label: '转正日期', value: f.regular_date },
|
||||
{ label: '离职日期', value: f.leave_date },
|
||||
{ label: '户籍地址', value: f.household_address },
|
||||
{ label: '现居住地', value: f.current_address }
|
||||
]
|
||||
})
|
||||
|
||||
const contactInfo = computed(() => {
|
||||
const f = file.value || {}
|
||||
return [
|
||||
{ label: '手机号码', value: f.phone },
|
||||
{ label: '工作邮箱', value: f.work_email },
|
||||
{ label: '个人邮箱', value: f.email },
|
||||
{ label: '家庭住址', value: f.home_address }
|
||||
]
|
||||
})
|
||||
|
||||
const emergencyInfo = computed(() => {
|
||||
const f = file.value || {}
|
||||
return [
|
||||
{ label: '联系人', value: f.emergency_contact },
|
||||
{ label: '联系电话', value: f.emergency_phone },
|
||||
{ label: '关系', value: f.emergency_relation }
|
||||
]
|
||||
})
|
||||
|
||||
const recordGroups = computed(() => {
|
||||
return FILE_RECORD_TYPE_OPTIONS.map(option => ({
|
||||
type: option.value,
|
||||
label: option.label,
|
||||
list: records.value.filter(r => Number(r.type) === option.value)
|
||||
}))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadFile()
|
||||
})
|
||||
|
||||
async function loadFile() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getMyEmployeeFile()
|
||||
file.value = (data && data.file) || null
|
||||
records.value = (data && data.records) || []
|
||||
} catch (e) {
|
||||
file.value = null
|
||||
records.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(value) {
|
||||
if (!value) return ''
|
||||
const text = String(value)
|
||||
return text.length >= 10 ? text.slice(0, 10) : text
|
||||
}
|
||||
|
||||
function recordDate(record) {
|
||||
const start = fmtDate(record.start_date)
|
||||
const end = fmtDate(record.end_date)
|
||||
if (!start && !end) return ''
|
||||
if (start && end) return `${start} ~ ${end}`
|
||||
return start || end
|
||||
}
|
||||
|
||||
function absoluteUrl(url) {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
const base = (getBaseURL() || '').replace(/\/$/, '')
|
||||
return base + (url.startsWith('/') ? url : '/' + url)
|
||||
}
|
||||
|
||||
function openAttachment(url) {
|
||||
const full = absoluteUrl(url)
|
||||
if (!full) return
|
||||
uni.setClipboardData({
|
||||
data: full,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.body-inner {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
||||
box-shadow: $shadow-float;
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
border-radius: $radius-full;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-avatar text {
|
||||
font-size: 44rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.group-card {
|
||||
@include card;
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.group-bar {
|
||||
width: 6rpx;
|
||||
height: 26rpx;
|
||||
border-radius: 3rpx;
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 29rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.group-extra {
|
||||
margin-left: auto;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 14rpx;
|
||||
margin-top: 20rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-bg-page;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: $radius-full;
|
||||
background: linear-gradient(90deg, #60a5fa 0%, #2563eb 100%);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-size: 27rpx;
|
||||
color: $color-text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-text {
|
||||
font-size: 27rpx;
|
||||
color: $color-text;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-sub {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
margin-top: 10rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.record-extra {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-desc {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: $color-text-muted;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.record-empty {
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.record-empty-text {
|
||||
font-size: 25rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 200rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<scroll-view class="body" scroll-y :show-scrollbar="false">
|
||||
<view class="body-inner">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="!notice"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
text="公告不存在或已被删除"
|
||||
/>
|
||||
<block v-else>
|
||||
<view class="detail-card">
|
||||
<view class="tag-row">
|
||||
<u-tag v-if="notice.is_top" text="置顶" type="error" size="mini" />
|
||||
<u-tag
|
||||
:text="getNoticeLevelLabel(notice.level)"
|
||||
:type="levelTagType(notice.level)"
|
||||
size="mini"
|
||||
plain
|
||||
/>
|
||||
<u-tag :text="getNoticeTypeLabel(notice.notice_type)" type="primary" size="mini" plain />
|
||||
</view>
|
||||
<text class="title">{{ notice.title }}</text>
|
||||
<view class="meta">
|
||||
<text>{{ notice.publisher_name || '系统' }}</text>
|
||||
<text>·</text>
|
||||
<text>{{ notice.publish_time || '-' }}</text>
|
||||
</view>
|
||||
<view class="stat-row">
|
||||
<view class="stat-item">
|
||||
<FaIcon name="eye" color="#8a94ad" :size="13" />
|
||||
<text>{{ notice.read_count || 0 }} 次阅读</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-card">
|
||||
<text class="content">{{ notice.content || '(无正文)' }}</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getNotice, getNoticeTypeLabel, getNoticeLevelLabel } from '@/api/oa.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const id = ref(0)
|
||||
const notice = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
id.value = Number((current && current.options && current.options.id) || 0)
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
/** 公告级别 → u-tag 类型:1 重要 / 2 紧急 / 其他 普通 */
|
||||
function levelTagType(level) {
|
||||
const n = Number(level)
|
||||
if (n === 1) return 'warning'
|
||||
if (n === 2) return 'error'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!id.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
notice.value = await getNotice(id.value)
|
||||
} catch (e) {
|
||||
notice.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.body-inner {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
@include card;
|
||||
padding: 32rpx 28rpx;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 38rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
margin-top: 18rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
margin-top: 18rpx;
|
||||
padding-top: 18rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
@include card;
|
||||
margin-top: 20rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 29rpx;
|
||||
line-height: 1.75;
|
||||
color: $color-text;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 200rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="toolbar">
|
||||
<u-search
|
||||
v-model="keyword"
|
||||
placeholder="搜索公告标题"
|
||||
:show-action="false"
|
||||
shape="round"
|
||||
bg-color="#f1f5f9"
|
||||
color="#0f172a"
|
||||
@search="loadNotices"
|
||||
@clear="clearSearch"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="list.length === 0"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
:text="keyword ? '未找到相关公告' : '暂无公告'"
|
||||
/>
|
||||
<view v-else class="notice-list">
|
||||
<view v-for="item in list" :key="item.id" class="notice-card" @tap="openDetail(item.id)">
|
||||
<view class="notice-head">
|
||||
<u-tag v-if="item.is_top" text="置顶" type="error" size="mini" />
|
||||
<u-tag :text="getNoticeLevelLabel(item.level)" :type="levelTagType(item.level)" size="mini" plain />
|
||||
<text class="notice-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<text v-if="item.summary" class="notice-summary">{{ item.summary }}</text>
|
||||
<view class="notice-meta">
|
||||
<text class="meta-type">{{ getNoticeTypeLabel(item.notice_type) }}</text>
|
||||
<text>{{ item.publisher_name || '系统' }}</text>
|
||||
<text>·</text>
|
||||
<text>{{ item.publish_time || '-' }}</text>
|
||||
<text>·</text>
|
||||
<text>阅读 {{ item.read_count || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchNotices, getNoticeTypeLabel, getNoticeLevelLabel } from '@/api/oa.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const keyword = ref('')
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadNotices()
|
||||
})
|
||||
|
||||
async function loadNotices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchNotices({ keyword: keyword.value, pageSize: 50 })
|
||||
list.value = res.list
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
keyword.value = ''
|
||||
loadNotices()
|
||||
}
|
||||
|
||||
/** 公告级别 → uview tag 类型 */
|
||||
function levelTagType(level) {
|
||||
const n = Number(level)
|
||||
if (n === 1) return 'warning'
|
||||
if (n === 2) return 'error'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
uni.navigateTo({ url: `/pages/oa/notice/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 20rpx $page-padding-x;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
@include card;
|
||||
padding: 26rpx 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.notice-summary {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
color: $color-text-secondary;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notice-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
margin-top: 16rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.meta-type {
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="profile-header">
|
||||
<view class="profile-header" :style="headPad">
|
||||
<view class="profile-info">
|
||||
<view class="profile-avatar">
|
||||
<text class="avatar-text">{{ avatarText }}</text>
|
||||
@@ -24,27 +24,34 @@
|
||||
|
||||
<view class="main-scroll" style="margin-top:10rpx">
|
||||
<view class="menu-group" v-for="(group, gIndex) in menuGroups" :key="gIndex">
|
||||
<view
|
||||
class="menu-item"
|
||||
v-for="item in group"
|
||||
:key="item.title"
|
||||
@tap="onMenuTap(item)"
|
||||
>
|
||||
<view class="menu-left">
|
||||
<view class="menu-icon">
|
||||
<FaIcon :name="item.icon" color="#3973ff" :size="18" />
|
||||
</view>
|
||||
<text class="menu-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<view class="menu-right">
|
||||
<text v-if="item.badge" class="menu-badge">{{ item.badge }}</text>
|
||||
<FaIcon name="chevron-right" color="#c0c4cc" :size="12" />
|
||||
</view>
|
||||
</view>
|
||||
<u-cell-group :border="false">
|
||||
<u-cell
|
||||
v-for="item in group"
|
||||
:key="item.title"
|
||||
:title="item.title"
|
||||
is-link
|
||||
@click="onMenuTap(item)"
|
||||
>
|
||||
<template #icon>
|
||||
<view class="menu-icon">
|
||||
<FaIcon :name="item.icon" color="#2563eb" :size="18" />
|
||||
</view>
|
||||
</template>
|
||||
<template #value>
|
||||
<text v-if="item.badge" class="menu-badge">{{ item.badge }}</text>
|
||||
</template>
|
||||
</u-cell>
|
||||
</u-cell-group>
|
||||
</view>
|
||||
|
||||
<view class="logout-btn" hover-class="logout-btn-hover" @tap.stop="handleLogout">
|
||||
<text class="logout-text">退出登录</text>
|
||||
<view class="logout-wrap">
|
||||
<u-button
|
||||
type="error"
|
||||
plain
|
||||
shape="circle"
|
||||
text="退出登录"
|
||||
@click="handleLogout"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="bottom-space" />
|
||||
@@ -56,6 +63,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
|
||||
const headPad = mpHeadPad()
|
||||
import { getUser, setUser, logout, isLoggedIn, isSSOEnabled, getToken } from '@/utils/auth.js'
|
||||
import { getCurrentUser, logoutApi, logoutUAC } from '@/api/auth.js'
|
||||
import AppTabbar from '@/components/MainTabbar.vue'
|
||||
@@ -172,6 +182,9 @@ function handleLogout() {
|
||||
.profile-header {
|
||||
flex-shrink: 0;
|
||||
padding: calc(var(--status-bar-height, 44px) + 24rpx) $page-padding-x 40rpx;
|
||||
/* #ifdef MP-WEIXIN */
|
||||
padding-top: calc(env(safe-area-inset-top, 25px) + 88rpx);
|
||||
/* #endif */
|
||||
background: $hero-gradient;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -307,31 +320,6 @@ function handleLogout() {
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 32rpx 28rpx;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
|
||||
& + & {
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
transform: translateX(4rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
@@ -343,55 +331,21 @@ function handleLogout() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.menu-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #60a5fa 100%);
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 12rpx;
|
||||
min-width: 36rpx;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 4rpx 12rpx rgba(57, 115, 255, 0.3);
|
||||
box-shadow: 0 4rpx 12rpx rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
@include card;
|
||||
height: 100rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.logout-wrap {
|
||||
margin-bottom: 12rpx;
|
||||
border-radius: 16rpx;
|
||||
background: linear-gradient(135deg, rgba(245, 108, 108, 0.1) 0%, rgba(245, 108, 108, 0.05) 100%);
|
||||
border: 1rpx solid rgba(245, 108, 108, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logout-btn-hover {
|
||||
background: linear-gradient(135deg, rgba(245, 108, 108, 0.2) 0%, rgba(245, 108, 108, 0.1) 100%) !important;
|
||||
box-shadow: 0 4rpx 16rpx rgba(245, 108, 108, 0.15) !important;
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部子系统标识条 -->
|
||||
<view v-if="sub" class="hero" :class="sub.tint" :style="headPad">
|
||||
<view class="hero-back" @tap="goBackNav">
|
||||
<FaIcon name="chevron-left" color="#ffffff" :size="14" />
|
||||
</view>
|
||||
<view class="hero-main">
|
||||
<view class="hero-icon">
|
||||
<FaIcon :name="sub.icon" color="#ffffff" :size="24" />
|
||||
</view>
|
||||
<view class="hero-meta">
|
||||
<text class="hero-name">{{ sub.title }}</text>
|
||||
<text class="hero-desc">{{ sub.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!--
|
||||
内容区:三页签共用底部 tabbar,H5 / APK / 微信小程序结构完全一致。
|
||||
各自独立 scroll-view(保留各自滚动位置);首页进入即挂载,
|
||||
其余页签首次切换才挂载,之后用 v-show 保留状态。
|
||||
-->
|
||||
<view v-if="sub" class="content">
|
||||
<scroll-view v-if="visited.home" v-show="activeTab === 'home'" class="pane" scroll-y>
|
||||
<SubHome :slug="slug" embedded @switch-tab="switchByIndex" @back="goBackNav" />
|
||||
<view class="pane-bottom" />
|
||||
</scroll-view>
|
||||
<scroll-view v-if="visited.functions" v-show="activeTab === 'functions'" class="pane" scroll-y>
|
||||
<SubFunctions :slug="slug" embedded @switch-tab="switchByIndex" @back="goBackNav" />
|
||||
<view class="pane-bottom" />
|
||||
</scroll-view>
|
||||
<scroll-view v-if="visited.mine" v-show="activeTab === 'mine'" class="pane" scroll-y>
|
||||
<SubMine :slug="slug" embedded @switch-tab="switchByIndex" @back="goBackNav" />
|
||||
<view class="pane-bottom" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<AppTabbar variant="switch" :current="activeTab" :tabs="tabs" @change="onTabChange" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getSubsystem } from '@/config/subsystems.js'
|
||||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
import AppTabbar from '@/components/AppTabbar.vue'
|
||||
import SubHome from '@/components/sub/SubHome.vue'
|
||||
import SubFunctions from '@/components/sub/SubFunctions.vue'
|
||||
import SubMine from '@/components/sub/SubMine.vue'
|
||||
|
||||
// 页签下标顺序,与 Sub 组件 @switch-tab 抛出的数字一一对应
|
||||
const TAB_ORDER = ['home', 'functions', 'mine']
|
||||
const tabs = [
|
||||
{ name: 'home', text: '首页', icon: 'house' },
|
||||
{ name: 'functions', text: '功能', icon: 'table-cells' },
|
||||
{ name: 'mine', text: '我的', icon: 'user' }
|
||||
]
|
||||
|
||||
const slug = ref('')
|
||||
const sub = ref(null)
|
||||
const activeTab = ref('home')
|
||||
const visited = ref({ home: true, functions: false, mine: false })
|
||||
|
||||
const headPad = mpHeadPad()
|
||||
|
||||
onLoad((options) => {
|
||||
slug.value = options && options.slug ? options.slug : 'crm'
|
||||
sub.value = getSubsystem(slug.value)
|
||||
if (!sub.value) uni.reLaunch({ url: '/pages/nav/nav' })
|
||||
})
|
||||
|
||||
function onTabChange(name) {
|
||||
activeTab.value = name
|
||||
if (!visited.value[name]) visited.value[name] = true
|
||||
}
|
||||
|
||||
function switchByIndex(i) {
|
||||
onTabChange(TAB_ORDER[i] || 'home')
|
||||
}
|
||||
|
||||
function goBackNav() {
|
||||
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/nav/nav' }) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: $color-bg-page;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============ 顶部标识条 ============ */
|
||||
.hero {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
// H5 端 --status-bar-height 为 0,此时仅保留 32rpx 内边距
|
||||
padding: calc(var(--status-bar-height, 44px) + 32rpx) $page-padding-x 72rpx;
|
||||
}
|
||||
|
||||
.hero-back {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 26rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.32);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
/* ============ 内容区:上移压住渐变条,圆角衔接 ============ */
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: -44rpx;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
background: $color-bg-page;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pane-bottom {
|
||||
height: 32rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -3,13 +3,15 @@
|
||||
<view class="form-card">
|
||||
<view class="field">
|
||||
<text class="field-label">标题</text>
|
||||
<input
|
||||
class="field-input title-input"
|
||||
<u-input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
placeholder="输入标题"
|
||||
placeholder-class="placeholder"
|
||||
maxlength="80"
|
||||
placeholder-style="color:#94a3b8"
|
||||
:maxlength="80"
|
||||
border="none"
|
||||
:font-size="15"
|
||||
color="#0f172a"
|
||||
clearable
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -22,13 +24,14 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="switch-row" @tap="form.pinned = !form.pinned">
|
||||
<view class="switch-left">
|
||||
<FaIcon name="thumbtack" :color="form.pinned ? '#3c9cff' : '#909399'" :size="16" />
|
||||
<text class="switch-label">置顶笔记</text>
|
||||
</view>
|
||||
<view class="switch-dot" :class="{ on: form.pinned }" />
|
||||
</view>
|
||||
<u-cell class="switch-cell" :border="false" title="置顶笔记">
|
||||
<template #icon>
|
||||
<FaIcon name="thumbtack" :color="form.pinned ? '#2563eb' : '#909399'" :size="16" />
|
||||
</template>
|
||||
<template #value>
|
||||
<u-switch v-model="form.pinned" active-color="#2563eb" size="20" />
|
||||
</template>
|
||||
</u-cell>
|
||||
</view>
|
||||
|
||||
<view v-if="noteId && meta.updatedAt" class="meta">
|
||||
@@ -36,9 +39,19 @@
|
||||
</view>
|
||||
|
||||
<view class="footer">
|
||||
<view v-if="noteId" class="btn-danger" @tap="onDelete">删除</view>
|
||||
<view class="btn-primary" :class="{ disabled: saving }" @tap="onSave">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
<view v-if="noteId" class="footer-btn">
|
||||
<u-button type="error" plain text="删除" @click="onDelete" />
|
||||
</view>
|
||||
<view class="footer-btn">
|
||||
<u-button
|
||||
type="primary"
|
||||
color="#2563eb"
|
||||
shape="circle"
|
||||
:loading="saving"
|
||||
loading-text="保存中..."
|
||||
text="保存"
|
||||
@click="onSave"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -175,17 +188,6 @@ function onDelete() {
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
font-size: 30rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -208,51 +210,10 @@ function onDelete() {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx;
|
||||
.switch-cell {
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.switch-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.switch-dot {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
border: 1rpx solid $color-border;
|
||||
background: $color-card;
|
||||
|
||||
&.on {
|
||||
background: $color-primary;
|
||||
border-color: $color-primary;
|
||||
box-shadow: inset 0 0 0 6rpx #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 20rpx 0;
|
||||
padding: 0 8rpx;
|
||||
@@ -275,34 +236,7 @@ function onDelete() {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-danger {
|
||||
.footer-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: $radius-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: $color-primary;
|
||||
color: #fff;
|
||||
|
||||
&:active {
|
||||
background: $color-primary-dark;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: $color-bg-page;
|
||||
color: #f56c6c;
|
||||
border: 1rpx solid #fde2e2;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,16 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="toolbar">
|
||||
<view class="search-bar">
|
||||
<FaIcon name="magnifying-glass" color="#909399" :size="16" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索笔记标题或内容"
|
||||
placeholder-class="search-placeholder"
|
||||
confirm-type="search"
|
||||
@confirm="loadNotes"
|
||||
/>
|
||||
<view v-if="keyword" class="search-clear" @tap="clearSearch">
|
||||
<FaIcon name="circle-xmark" color="#c0c4cc" :size="16" />
|
||||
</view>
|
||||
</view>
|
||||
<u-search
|
||||
v-model="keyword"
|
||||
placeholder="搜索笔记标题或内容"
|
||||
:show-action="false"
|
||||
shape="round"
|
||||
bg-color="#f1f5f9"
|
||||
color="#0f172a"
|
||||
@search="loadNotes"
|
||||
@clear="clearSearch"
|
||||
/>
|
||||
<view class="stats-row">
|
||||
<text class="stats-text">共 {{ total }} 条</text>
|
||||
<text v-if="pinnedCount" class="stats-text">置顶 {{ pinnedCount }} 条</text>
|
||||
@@ -26,10 +21,12 @@
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="notes.length === 0" class="state-box">
|
||||
<FaIcon name="note-sticky" color="#c0c4cc" :size="40" />
|
||||
<text class="state-text">{{ keyword ? '未找到相关笔记' : '暂无笔记,点击右下角新建' }}</text>
|
||||
</view>
|
||||
<u-empty
|
||||
v-else-if="notes.length === 0"
|
||||
mode="list"
|
||||
:icon-size="80"
|
||||
:text="keyword ? '未找到相关笔记' : '暂无笔记,点击右下角新建'"
|
||||
/>
|
||||
<view v-else class="note-list">
|
||||
<view
|
||||
v-for="item in notes"
|
||||
@@ -39,7 +36,7 @@
|
||||
>
|
||||
<view class="note-head">
|
||||
<view class="note-title-row">
|
||||
<text v-if="item.pinned" class="pin-tag">置顶</text>
|
||||
<u-tag v-if="item.pinned" text="置顶" type="primary" size="mini" plain class="pin-tag" />
|
||||
<text class="note-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<text class="note-time">{{ formatRelativeTime(item.updatedAt) }}</text>
|
||||
@@ -182,33 +179,6 @@ function onDelete(item) {
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: $color-text-muted;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
display: flex;
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
@@ -258,11 +228,6 @@ function onDelete(item) {
|
||||
|
||||
.pin-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 20rpx;
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.note-title {
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
<view class="content-block">
|
||||
<view class="content-header">
|
||||
<text class="info-label">日程内容</text>
|
||||
<view class="status-tag" :class="item.completed ? 'done' : 'pending'">
|
||||
<text>{{ item.completed ? '已完成' : '待办' }}</text>
|
||||
</view>
|
||||
<u-tag
|
||||
:text="item.completed ? '已完成' : '待办'"
|
||||
:type="item.completed ? 'success' : 'primary'"
|
||||
size="mini"
|
||||
/>
|
||||
</view>
|
||||
<text class="content-text">{{ item.content || '无内容' }}</text>
|
||||
</view>
|
||||
@@ -23,11 +25,14 @@
|
||||
<view class="info-row channel-row">
|
||||
<text class="info-label">提醒渠道</text>
|
||||
<view class="channel-tags">
|
||||
<text
|
||||
<u-tag
|
||||
v-for="channel in item.remindChannels"
|
||||
:key="channel"
|
||||
class="channel-tag"
|
||||
>{{ getRemindChannelLabel(channel) }}</text>
|
||||
:text="getRemindChannelLabel(channel)"
|
||||
type="primary"
|
||||
size="mini"
|
||||
plain
|
||||
/>
|
||||
<text v-if="!item.remindChannels?.length" class="info-value muted">未设置</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -45,17 +50,20 @@
|
||||
</template>
|
||||
|
||||
<view v-if="item" class="footer">
|
||||
<view class="btn-complete" @tap="onToggleComplete">
|
||||
<FaIcon :name="item.completed ? 'rotate-left' : 'check'" :color="item.completed ? '#606266' : '#3c9cff'" :size="16" />
|
||||
<text>{{ item.completed ? '标为待办' : '标记完成' }}</text>
|
||||
<view class="footer-btn">
|
||||
<u-button
|
||||
:type="item.completed ? 'info' : 'primary'"
|
||||
:icon="item.completed ? 'reload' : 'checkmark'"
|
||||
:text="item.completed ? '标为待办' : '标记完成'"
|
||||
:plain="item.completed"
|
||||
@click="onToggleComplete"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="!item.completed" class="btn-edit" @tap="openEdit">
|
||||
<FaIcon name="pen-to-square" color="#3c9cff" :size="16" />
|
||||
<text>编辑</text>
|
||||
<view v-if="!item.completed" class="footer-btn">
|
||||
<u-button type="primary" plain icon="edit-pen" text="编辑" @click="openEdit" />
|
||||
</view>
|
||||
<view class="btn-delete" @tap="onDelete">
|
||||
<FaIcon name="trash-can" color="#f56c6c" :size="16" />
|
||||
<text>删除</text>
|
||||
<view class="footer-btn">
|
||||
<u-button type="error" plain icon="trash" text="删除" @click="onDelete" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -116,11 +124,13 @@ function openEdit() {
|
||||
|
||||
async function onToggleComplete() {
|
||||
if (!item.value) return
|
||||
const wasCompleted = item.value.completed
|
||||
try {
|
||||
const updated = await toggleScheduleComplete(item.value.id)
|
||||
item.value = updated
|
||||
await toggleScheduleComplete(item.value.id)
|
||||
// toggle 接口只返回 is_finished,需重新拉取完整详情
|
||||
await loadDetail()
|
||||
uni.showToast({
|
||||
title: updated.completed ? '已标记完成' : '已标为待办',
|
||||
title: wasCompleted ? '已标为待办' : '已标记完成',
|
||||
icon: 'none'
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -180,23 +190,6 @@ function onDelete() {
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
|
||||
&.pending {
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&.done {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
|
||||
.content-text {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
@@ -244,14 +237,6 @@ function onDelete() {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.channel-tag {
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -277,35 +262,8 @@ function onDelete() {
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.btn-complete,
|
||||
.btn-edit,
|
||||
.btn-delete {
|
||||
.footer-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: $radius-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-complete {
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: $color-bg-page;
|
||||
color: $color-primary;
|
||||
border: 1rpx solid $color-primary-bg;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: $color-bg-page;
|
||||
color: #f56c6c;
|
||||
border: 1rpx solid #fde2e2;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
|
||||
@@ -8,16 +8,15 @@
|
||||
<view class="form-card" :class="{ 'is-disabled': isCompleted }">
|
||||
<view class="field">
|
||||
<text class="field-label">日程内容 <text class="required">*</text></text>
|
||||
<textarea
|
||||
class="field-textarea content-textarea"
|
||||
<u-textarea
|
||||
v-model="form.content"
|
||||
placeholder="输入日程内容,支持多行"
|
||||
placeholder-class="placeholder"
|
||||
maxlength="1000"
|
||||
:show-confirm-bar="false"
|
||||
:maxlength="1000"
|
||||
count
|
||||
height="120"
|
||||
border="none"
|
||||
:disabled="isCompleted"
|
||||
/>
|
||||
<text class="char-count">{{ form.content.length }}/1000</text>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
@@ -44,15 +43,16 @@
|
||||
<text class="field-label">提醒渠道</text>
|
||||
<text class="field-hint">可多选,接入接口后按渠道下发提醒</text>
|
||||
<view class="channel-grid">
|
||||
<view
|
||||
<u-tag
|
||||
v-for="channel in channelOptions"
|
||||
:key="channel.value"
|
||||
class="channel-chip"
|
||||
:class="{ active: form.remindChannels.includes(channel.value) }"
|
||||
@tap="!isCompleted && toggleChannel(channel.value)"
|
||||
>
|
||||
<text>{{ channel.label }}</text>
|
||||
</view>
|
||||
:text="channel.label"
|
||||
:name="channel.value"
|
||||
:type="isChannelActive(channel.value) ? 'primary' : 'info'"
|
||||
:plain="!isChannelActive(channel.value)"
|
||||
size="medium"
|
||||
@click="onChannelTap"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -68,41 +68,39 @@
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">重复提醒间隔(分钟)</text>
|
||||
<view class="number-input">
|
||||
<button class="number-btn" @tap="form.repeatIntervalMinutes = Math.max(0, form.repeatIntervalMinutes - 1)" :disabled="isCompleted">−</button>
|
||||
<input
|
||||
v-model.number="form.repeatIntervalMinutes"
|
||||
type="number"
|
||||
class="number-field"
|
||||
:disabled="isCompleted"
|
||||
@blur="form.repeatIntervalMinutes = Math.max(0, Math.min(1440, form.repeatIntervalMinutes))"
|
||||
/>
|
||||
<button class="number-btn" @tap="form.repeatIntervalMinutes = Math.min(1440, form.repeatIntervalMinutes + 1)" :disabled="isCompleted">+</button>
|
||||
</view>
|
||||
<u-number-box
|
||||
v-model="form.repeatIntervalMinutes"
|
||||
:min="0"
|
||||
:max="1440"
|
||||
integer
|
||||
:disabled="isCompleted"
|
||||
/>
|
||||
<text class="form-tip">0表示不重复,仅发送一次</text>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">最多提醒次数</text>
|
||||
<view class="number-input">
|
||||
<button class="number-btn" @tap="form.maxSendCount = Math.max(1, form.maxSendCount - 1)" :disabled="isCompleted">−</button>
|
||||
<input
|
||||
v-model.number="form.maxSendCount"
|
||||
type="number"
|
||||
class="number-field"
|
||||
:disabled="isCompleted"
|
||||
@blur="form.maxSendCount = Math.max(1, Math.min(100, form.maxSendCount))"
|
||||
/>
|
||||
<button class="number-btn" @tap="form.maxSendCount = Math.min(100, form.maxSendCount + 1)" :disabled="isCompleted">+</button>
|
||||
</view>
|
||||
<u-number-box
|
||||
v-model="form.maxSendCount"
|
||||
:min="1"
|
||||
:max="100"
|
||||
integer
|
||||
:disabled="isCompleted"
|
||||
/>
|
||||
<text class="form-tip">最多发送的提醒次数</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!isCompleted" class="footer">
|
||||
<view class="btn-primary full" :class="{ disabled: saving }" @tap="onSave">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</view>
|
||||
<u-button
|
||||
type="primary"
|
||||
color="#2563eb"
|
||||
shape="circle"
|
||||
:loading="saving"
|
||||
loading-text="保存中..."
|
||||
text="保存"
|
||||
@click="onSave"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -177,12 +175,18 @@ async function loadSchedule(id) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleChannel(value) {
|
||||
const index = form.remindChannels.indexOf(value)
|
||||
/** 渠道是否已选中 */
|
||||
function isChannelActive(value) {
|
||||
return form.remindChannels.includes(value)
|
||||
}
|
||||
|
||||
function onChannelTap(name) {
|
||||
if (isCompleted.value) return
|
||||
const index = form.remindChannels.indexOf(name)
|
||||
if (index >= 0) {
|
||||
form.remindChannels.splice(index, 1)
|
||||
} else {
|
||||
form.remindChannels.push(value)
|
||||
form.remindChannels.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,29 +319,6 @@ async function saveData(datetime) {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
font-size: 30rpx;
|
||||
color: $color-text;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
height: 240rpx;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -356,69 +337,6 @@ async function saveData(datetime) {
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.channel-chip {
|
||||
padding: 14rpx 28rpx;
|
||||
border-radius: $radius-full;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
background: $color-bg-page;
|
||||
border: 1rpx solid transparent;
|
||||
|
||||
&.active {
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
border-color: $color-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.number-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.number-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-page;
|
||||
border: 1rpx solid $color-divider;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.number-field {
|
||||
flex: 1;
|
||||
height: 60rpx;
|
||||
padding: 0 16rpx;
|
||||
background: $color-bg-page;
|
||||
border: 1rpx solid $color-divider;
|
||||
border-radius: $radius-md;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
text-align: center;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
@@ -439,29 +357,4 @@ async function saveData(datetime) {
|
||||
border-top: 1rpx solid $color-border;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: $radius-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
background: $color-primary;
|
||||
color: #fff;
|
||||
|
||||
&:active {
|
||||
background: $color-primary-dark;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.full {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +1,133 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="toolbar">
|
||||
<view class="search-bar">
|
||||
<FaIcon name="magnifying-glass" color="#909399" :size="16" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索日程内容"
|
||||
placeholder-class="search-placeholder"
|
||||
confirm-type="search"
|
||||
@confirm="loadSchedules"
|
||||
<scroll-view class="body" scroll-y :show-scrollbar="false">
|
||||
<view class="body-inner">
|
||||
<!-- 本周概览横幅 -->
|
||||
<view class="hero-banner">
|
||||
<view class="hero-info">
|
||||
<view class="hero-title-row">
|
||||
<text class="hero-title">本周日程</text>
|
||||
<text class="hero-unit">(项)</text>
|
||||
<text class="hero-count">{{ weekCount }}</text>
|
||||
</view>
|
||||
<text class="hero-sub">{{ heroRangeLabel }}</text>
|
||||
</view>
|
||||
<view class="hero-icon">
|
||||
<FaIcon name="calendar-check" color="#ffffff" :size="26" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 可收缩日历 -->
|
||||
<view class="cal-card">
|
||||
<view v-if="expanded" class="cal-head">
|
||||
<text class="cal-title">{{ monthLabel }}</text>
|
||||
<view class="cal-head-right">
|
||||
<view class="cal-nav" @tap="shiftMonth(-1)">
|
||||
<FaIcon name="chevron-left" color="#475569" :size="13" />
|
||||
</view>
|
||||
<view class="cal-today-btn" @tap="goToday">
|
||||
<text>今天</text>
|
||||
</view>
|
||||
<view class="cal-nav" @tap="shiftMonth(1)">
|
||||
<FaIcon name="chevron-right" color="#475569" :size="13" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cal-week">
|
||||
<text v-for="w in WEEK_LABELS" :key="w" class="cal-week-item">{{ w }}</text>
|
||||
</view>
|
||||
|
||||
<view class="cal-grid">
|
||||
<view
|
||||
v-for="cell in gridCells"
|
||||
:key="cell.key"
|
||||
class="cal-cell"
|
||||
@tap="pickDay(cell)"
|
||||
>
|
||||
<view
|
||||
class="cal-day"
|
||||
:class="{
|
||||
dim: !cell.inMonth,
|
||||
today: cell.isToday && cell.key !== selectedKey,
|
||||
selected: cell.key === selectedKey
|
||||
}"
|
||||
>{{ cell.day }}</view>
|
||||
<view class="cal-dots">
|
||||
<view
|
||||
v-for="n in dotCount(cell.key)"
|
||||
:key="n"
|
||||
class="cal-dot"
|
||||
:class="{ on: cell.key === selectedKey }"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cal-toggle" @tap="expanded = !expanded">
|
||||
<text>{{ expanded ? '收起' : '展开' }}</text>
|
||||
<FaIcon :name="expanded ? 'chevron-up' : 'chevron-down'" color="#94a3b8" :size="11" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 选中日期的日程 -->
|
||||
<view class="day-head">
|
||||
<text class="day-title">{{ selectedLabel }}</text>
|
||||
<text class="day-count">{{ dayItems.length }} 项</text>
|
||||
</view>
|
||||
|
||||
<u-empty
|
||||
v-if="!loading && dayItems.length === 0"
|
||||
mode="list"
|
||||
:icon-size="72"
|
||||
text="当天暂无日程"
|
||||
/>
|
||||
<view v-if="keyword" class="search-clear" @tap="clearSearch">
|
||||
<FaIcon name="circle-xmark" color="#c0c4cc" :size="16" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="tab"
|
||||
:class="{ active: status === tab.value }"
|
||||
@tap="changeStatus(tab.value)"
|
||||
>{{ tab.label }}</view>
|
||||
</view>
|
||||
</view>
|
||||
v-for="item in dayItems"
|
||||
:key="item.id"
|
||||
class="sched-card"
|
||||
:class="{ done: item.completed }"
|
||||
>
|
||||
<view class="sched-main" @tap="openDetail(item.id)">
|
||||
<view class="sched-left">
|
||||
<text class="sched-day">{{ dayLabel(item.datetime) }}</text>
|
||||
<text class="sched-time">{{ timeText(item.datetime) }}</text>
|
||||
</view>
|
||||
<view class="sched-sep" />
|
||||
<view class="sched-right">
|
||||
<view class="sched-title-row">
|
||||
<text class="sched-content">{{ item.content || item.title }}</text>
|
||||
<view class="status-chip" :class="item.completed ? 'done' : 'pending'">
|
||||
<text>{{ item.completed ? '已完成' : '待办' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.remindChannels.length" class="sched-meta">
|
||||
<FaIcon name="bell" color="#94a3b8" :size="11" />
|
||||
<text>{{ getRemindMinutesLabel(item.remindMinutes) }}</text>
|
||||
<text v-if="item.repeatIntervalMinutes > 0">· 每 {{ item.repeatIntervalMinutes }} 分钟</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="groupedList.length === 0" class="state-box">
|
||||
<FaIcon name="calendar-days" color="#c0c4cc" :size="40" />
|
||||
<text class="state-text">{{ keyword ? '未找到相关日程' : '暂无日程,点击右下角新建' }}</text>
|
||||
</view>
|
||||
<view v-else class="schedule-list">
|
||||
<view v-for="group in groupedList" :key="group.key" class="group">
|
||||
<text class="group-title">{{ group.label }}</text>
|
||||
<view class="action-row">
|
||||
<view class="action-btn" @tap="onToggleComplete(item)">
|
||||
<FaIcon :name="item.completed ? 'rotate-left' : 'check'" color="#8a94ad" :size="12" />
|
||||
<text>{{ item.completed ? '标为待办' : '标记完成' }}</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="openEdit(item.id)">
|
||||
<FaIcon name="pen" color="#8a94ad" :size="12" />
|
||||
<text>编辑</text>
|
||||
</view>
|
||||
<view class="action-btn danger" @tap="onDelete(item)">
|
||||
<FaIcon name="trash" color="#f56c6c" :size="12" />
|
||||
<text>删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
|
||||
<view class="fab" @tap="createSchedule">
|
||||
@@ -53,38 +139,153 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { fetchSchedules, toggleScheduleComplete, getScheduleSummary, getRemindChannelLabel, getRemindMinutesLabel } from '@/api/schedule.js'
|
||||
import { formatDate, dayLabel, isToday, startOfDay } from '@/utils/date.js'
|
||||
import {
|
||||
fetchSchedules,
|
||||
deleteSchedule,
|
||||
toggleScheduleComplete,
|
||||
getRemindMinutesLabel
|
||||
} from '@/api/schedule.js'
|
||||
import { pad, startOfDay, isToday, isTomorrow, dayLabel } from '@/utils/date.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const keyword = ref('')
|
||||
const status = ref('all')
|
||||
/** 周一为一周第一天 */
|
||||
const WEEK_LABELS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
const schedules = ref([])
|
||||
const loading = ref(false)
|
||||
/** 默认收缩,仅显示选中日所在周的一行 */
|
||||
const expanded = ref(false)
|
||||
const monthCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1))
|
||||
const selectedDate = ref(startOfDay(new Date()))
|
||||
|
||||
const tabs = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'pending', label: '待办' },
|
||||
{ value: 'done', label: '已完成' }
|
||||
]
|
||||
function dateKey(value) {
|
||||
const d = value instanceof Date ? value : new Date(value)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
const groupedList = computed(() => {
|
||||
const map = new Map()
|
||||
schedules.value.forEach(item => {
|
||||
const key = startOfDay(item.datetime).getTime()
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
key,
|
||||
label: dayLabel(item.datetime),
|
||||
isToday: isToday(item.datetime),
|
||||
items: []
|
||||
})
|
||||
}
|
||||
map.get(key).items.push(item)
|
||||
})
|
||||
return [...map.values()].sort((a, b) => a.key - b.key)
|
||||
const monthLabel = computed(
|
||||
() => `${monthCursor.value.getFullYear()}年${monthCursor.value.getMonth() + 1}月`
|
||||
)
|
||||
|
||||
const selectedKey = computed(() => dateKey(selectedDate.value))
|
||||
|
||||
/** 选中日所在周(周一~周日)的 7 个单元格 */
|
||||
const weekCells = computed(() => {
|
||||
const base = selectedDate.value
|
||||
const offset = (base.getDay() + 6) % 7
|
||||
const cells = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() - offset + i)
|
||||
cells.push({
|
||||
key: dateKey(d),
|
||||
date: d,
|
||||
day: d.getDate(),
|
||||
inMonth: true,
|
||||
isToday: isToday(d)
|
||||
})
|
||||
}
|
||||
return cells
|
||||
})
|
||||
|
||||
/** 月历 6×7 单元格,含上下月补齐日期 */
|
||||
const monthCells = computed(() => {
|
||||
const year = monthCursor.value.getFullYear()
|
||||
const month = monthCursor.value.getMonth()
|
||||
const offset = (new Date(year, month, 1).getDay() + 6) % 7
|
||||
const start = new Date(year, month, 1 - offset)
|
||||
const cells = []
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i)
|
||||
cells.push({
|
||||
key: dateKey(d),
|
||||
date: d,
|
||||
day: d.getDate(),
|
||||
inMonth: d.getMonth() === month,
|
||||
isToday: isToday(d)
|
||||
})
|
||||
}
|
||||
return cells
|
||||
})
|
||||
|
||||
const gridCells = computed(() => (expanded.value ? monthCells.value : weekCells.value))
|
||||
|
||||
/** 日程按日期聚合,key 为 YYYY-MM-DD */
|
||||
const dayMap = computed(() => {
|
||||
const map = {}
|
||||
schedules.value.forEach((item) => {
|
||||
if (!item.datetime) return
|
||||
const key = dateKey(new Date(item.datetime))
|
||||
if (!map[key]) map[key] = []
|
||||
map[key].push(item)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
/** 选中日期的日程,按时间升序 */
|
||||
const dayItems = computed(() => {
|
||||
const list = dayMap.value[selectedKey.value] || []
|
||||
return list.slice().sort((a, b) => a.datetime - b.datetime)
|
||||
})
|
||||
|
||||
/** 本周(周一~周日)范围与日程数 */
|
||||
const weekRange = computed(() => {
|
||||
const now = startOfDay(new Date())
|
||||
const offset = (now.getDay() + 6) % 7
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - offset)
|
||||
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 7)
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const weekCount = computed(() => {
|
||||
const { start, end } = weekRange.value
|
||||
return schedules.value.filter((item) => {
|
||||
if (!item.datetime) return false
|
||||
const d = new Date(item.datetime)
|
||||
return d >= start && d < end
|
||||
}).length
|
||||
})
|
||||
|
||||
const heroRangeLabel = computed(() => {
|
||||
const { start, end } = weekRange.value
|
||||
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate() - 1)
|
||||
return `${start.getMonth() + 1}月${start.getDate()}日 - ${last.getMonth() + 1}月${last.getDate()}日`
|
||||
})
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const d = selectedDate.value
|
||||
const week = `周${WEEK_LABELS[(d.getDay() + 6) % 7].slice(1)}`
|
||||
if (isToday(d)) return `今天 ${d.getMonth() + 1}月${d.getDate()}日`
|
||||
if (isTomorrow(d)) return `明天 ${d.getMonth() + 1}月${d.getDate()}日`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${week}`
|
||||
})
|
||||
|
||||
function dotCount(key) {
|
||||
return Math.min(3, (dayMap.value[key] || []).length)
|
||||
}
|
||||
|
||||
function timeText(timestamp) {
|
||||
const d = new Date(timestamp)
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function shiftMonth(step) {
|
||||
const cur = monthCursor.value
|
||||
monthCursor.value = new Date(cur.getFullYear(), cur.getMonth() + step, 1)
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
const now = new Date()
|
||||
monthCursor.value = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
selectedDate.value = startOfDay(now)
|
||||
}
|
||||
|
||||
function pickDay(cell) {
|
||||
selectedDate.value = startOfDay(cell.date)
|
||||
monthCursor.value = new Date(cell.date.getFullYear(), cell.date.getMonth(), 1)
|
||||
// 月历里选完日期后自动收回周栏
|
||||
if (expanded.value) expanded.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
@@ -100,10 +301,8 @@ onShow(() => {
|
||||
async function loadSchedules() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchSchedules({
|
||||
keyword: keyword.value,
|
||||
status: status.value
|
||||
})
|
||||
// 后端暂不支持按日期区间过滤,取最近一页在端上按月/按天聚合
|
||||
const res = await fetchSchedules({ pageSize: 100 })
|
||||
schedules.value = res.list
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
@@ -112,16 +311,6 @@ async function loadSchedules() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
keyword.value = ''
|
||||
loadSchedules()
|
||||
}
|
||||
|
||||
function changeStatus(value) {
|
||||
status.value = value
|
||||
loadSchedules()
|
||||
}
|
||||
|
||||
function createSchedule() {
|
||||
uni.navigateTo({ url: '/pages/tools/schedule/edit' })
|
||||
}
|
||||
@@ -146,175 +335,356 @@ async function onToggleComplete(item) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(item) {
|
||||
uni.showModal({
|
||||
title: '删除日程',
|
||||
content: '删除后该日程及其提醒将一并移除,确定继续吗?',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
await deleteSchedule(item.id)
|
||||
uni.showToast({ title: '已删除', icon: 'none' })
|
||||
await loadSchedules()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.body-inner {
|
||||
padding: 20rpx $page-padding-x 0;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
/* ---------- 本周概览横幅 ---------- */
|
||||
.hero-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: $hero-gradient;
|
||||
box-shadow: 0 10rpx 28rpx rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
font-size: 28rpx;
|
||||
.hero-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-unit {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.hero-count {
|
||||
margin-left: 8rpx;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- 可收缩日历 ---------- */
|
||||
.cal-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 16rpx 16rpx 8rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.cal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4rpx 8rpx 12rpx;
|
||||
}
|
||||
|
||||
.cal-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: $color-text-muted;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
.cal-head-right {
|
||||
display: flex;
|
||||
padding: 8rpx;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10rpx 28rpx;
|
||||
border-radius: $radius-full;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
.cal-nav {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.active {
|
||||
color: $color-primary;
|
||||
&:active {
|
||||
background: $color-primary-bg;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
.cal-today-btn {
|
||||
height: 52rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary;
|
||||
font-size: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.cal-week {
|
||||
display: flex;
|
||||
padding: 4rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.cal-week-item {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
text-align: center;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.schedule-list {
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
.cal-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 28rpx;
|
||||
.cal-cell {
|
||||
flex: 0 0 14.2857%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 6rpx 0;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
.cal-day {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 27rpx;
|
||||
color: $color-text;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.dim {
|
||||
color: $color-text-muted;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&.today {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: $color-primary;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cal-dots {
|
||||
height: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.cal-dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: $color-primary;
|
||||
|
||||
&.on {
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.cal-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 14rpx 0 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 当日日程 ---------- */
|
||||
.day-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 28rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.day-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text-secondary;
|
||||
margin-bottom: 16rpx;
|
||||
padding-left: 4rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
.day-count {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.sched-card {
|
||||
@include card;
|
||||
margin-bottom: 16rpx;
|
||||
overflow: hidden;
|
||||
|
||||
&.done .schedule-title {
|
||||
&.done .sched-content {
|
||||
text-decoration: line-through;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-main {
|
||||
padding: 24rpx 24rpx 16rpx;
|
||||
.sched-main {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 24rpx 8rpx;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
.sched-left {
|
||||
width: 128rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.pending {
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
&.done {
|
||||
background: #67c23a;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.schedule-title {
|
||||
flex: 1;
|
||||
font-size: 30rpx;
|
||||
.sched-day {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.sched-time {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-meta {
|
||||
.sched-sep {
|
||||
width: 1rpx;
|
||||
margin: 4rpx 20rpx;
|
||||
background: $color-divider;
|
||||
}
|
||||
|
||||
.sched-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sched-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.sched-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
color: $color-text;
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
@include chip;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.done {
|
||||
background: #ecfdf5;
|
||||
color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.sched-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
padding-left: 24rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
padding-left: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.channel-mini {
|
||||
font-size: 22rpx;
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.remind-text {
|
||||
font-size: 22rpx;
|
||||
margin-top: 10rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
@@ -329,61 +699,39 @@ async function onToggleComplete(item) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 20rpx 0;
|
||||
padding: 18rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
|
||||
& + & {
|
||||
border-left: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
color: $color-text-secondary;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
border-radius: 50%;
|
||||
background: $color-primary;
|
||||
box-shadow: 0 8rpx 24rpx rgba(60, 156, 255, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
background: $color-primary-dark;
|
||||
&.danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 160rpx;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(60rpx + env(safe-area-inset-bottom));
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
border-radius: 50%;
|
||||
background: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10rpx 28rpx rgba(37, 99, 235, 0.35);
|
||||
z-index: 20;
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 本周概览 -->
|
||||
<view class="hero-card">
|
||||
<view class="hero-left">
|
||||
<text class="hero-sub">查看每周日程提醒</text>
|
||||
<view class="hero-num-row">
|
||||
<text class="hero-label">本周日程(条)</text>
|
||||
<text class="hero-num">{{ weekItems.length }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-illus">
|
||||
<FaIcon name="bell" color="#ffffff" :size="26" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 周历条 -->
|
||||
<view class="week-card">
|
||||
<view
|
||||
v-for="d in weekDays"
|
||||
:key="d.key"
|
||||
class="week-item"
|
||||
@tap="pickWeekDay(d)"
|
||||
>
|
||||
<text class="week-name">{{ d.name }}</text>
|
||||
<view
|
||||
class="week-date"
|
||||
:class="{ today: d.isToday, selected: d.key === selectedKey }"
|
||||
>{{ d.day }}</view>
|
||||
<view class="week-dot" :class="{ show: d.count > 0 }" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的日程 -->
|
||||
<view class="section-head">
|
||||
<text class="section-title">我的日程({{ shownItems.length }})</text>
|
||||
<view class="range-tabs">
|
||||
<text class="range-tab" :class="{ active: range === 'day' }" @tap="showToday">今日</text>
|
||||
<text class="range-sep">|</text>
|
||||
<text class="range-tab" :class="{ active: range === 'week' }" @tap="range = 'week'">本周</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<u-empty
|
||||
v-if="!loading && shownItems.length === 0"
|
||||
mode="list"
|
||||
:icon-size="72"
|
||||
:text="range === 'week' ? '本周暂无日程提醒' : '当天暂无日程提醒'"
|
||||
/>
|
||||
<view v-else class="rem-list">
|
||||
<view v-for="item in shownItems" :key="item.id" class="rem-card" @tap="openDetail(item.id)">
|
||||
<view class="rem-time">
|
||||
<text class="rem-day">{{ shortDayLabel(item.datetime) }}</text>
|
||||
<text class="rem-clock">{{ timeText(item.datetime) }}</text>
|
||||
</view>
|
||||
<view class="rem-body">
|
||||
<text class="rem-title">{{ item.content || item.title }}</text>
|
||||
<view class="rem-meta">
|
||||
<FaIcon name="clock" color="#94a3b8" :size="11" />
|
||||
<text class="rem-meta-text">{{ fullTime(item.datetime) }}</text>
|
||||
</view>
|
||||
<view class="rem-meta">
|
||||
<FaIcon name="bell" color="#94a3b8" :size="11" />
|
||||
<text class="rem-meta-text">{{ remindLine(item) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchSchedules, getRemindChannelLabel, getRemindMinutesLabel } from '@/api/schedule.js'
|
||||
import { pad, startOfDay, isToday } from '@/utils/date.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
/** 周一为一周第一天 */
|
||||
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日']
|
||||
|
||||
const schedules = ref([])
|
||||
const loading = ref(false)
|
||||
const range = ref('day')
|
||||
const selectedDate = ref(startOfDay(new Date()))
|
||||
|
||||
function dateKey(value) {
|
||||
const d = value instanceof Date ? value : new Date(value)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
function startOfWeek(base = new Date()) {
|
||||
const d = startOfDay(base)
|
||||
const offset = (d.getDay() + 6) % 7
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() - offset)
|
||||
}
|
||||
|
||||
const weekStart = computed(() => startOfWeek(selectedDate.value))
|
||||
const selectedKey = computed(() => dateKey(selectedDate.value))
|
||||
|
||||
/** 只保留已设置提醒渠道的日程 */
|
||||
const reminders = computed(() =>
|
||||
schedules.value.filter((item) => item.remindChannels && item.remindChannels.length > 0)
|
||||
)
|
||||
|
||||
/** 提醒按日期聚合,key 为 YYYY-MM-DD */
|
||||
const reminderMap = computed(() => {
|
||||
const map = {}
|
||||
reminders.value.forEach((item) => {
|
||||
if (!item.datetime) return
|
||||
const key = dateKey(new Date(item.datetime))
|
||||
if (!map[key]) map[key] = []
|
||||
map[key].push(item)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const weekDays = computed(() => {
|
||||
const base = weekStart.value
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + i)
|
||||
const key = dateKey(d)
|
||||
return {
|
||||
key,
|
||||
date: d,
|
||||
name: '周' + WEEK_LABELS[i],
|
||||
day: d.getDate(),
|
||||
isToday: isToday(d),
|
||||
count: (reminderMap.value[key] || []).length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** 本周(周一~周日)的提醒,按时间升序 */
|
||||
const weekItems = computed(() => {
|
||||
const base = weekStart.value
|
||||
const startTs = base.getTime()
|
||||
const endTs = startTs + 7 * 24 * 60 * 60 * 1000
|
||||
return reminders.value
|
||||
.filter((item) => item.datetime >= startTs && item.datetime < endTs)
|
||||
.sort((a, b) => a.datetime - b.datetime)
|
||||
})
|
||||
|
||||
const shownItems = computed(() => {
|
||||
if (range.value === 'week') return weekItems.value
|
||||
return (reminderMap.value[selectedKey.value] || [])
|
||||
.slice()
|
||||
.sort((a, b) => a.datetime - b.datetime)
|
||||
})
|
||||
|
||||
function timeText(timestamp) {
|
||||
const d = new Date(timestamp)
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function fullTime(timestamp) {
|
||||
const d = new Date(timestamp)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${timeText(timestamp)}`
|
||||
}
|
||||
|
||||
function shortDayLabel(timestamp) {
|
||||
if (isToday(timestamp)) return '今天'
|
||||
const d = new Date(timestamp)
|
||||
return '周' + WEEK_LABELS[(d.getDay() + 6) % 7]
|
||||
}
|
||||
|
||||
function remindLine(item) {
|
||||
const channels = (item.remindChannels || [])
|
||||
.map((ch) => getRemindChannelLabel(ch))
|
||||
.join('、')
|
||||
const parts = [getRemindMinutesLabel(item.remindMinutes)]
|
||||
if (channels) parts.push(channels)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function pickWeekDay(d) {
|
||||
selectedDate.value = startOfDay(d.date)
|
||||
range.value = 'day'
|
||||
}
|
||||
|
||||
function showToday() {
|
||||
selectedDate.value = startOfDay(new Date())
|
||||
range.value = 'day'
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
uni.navigateTo({ url: `/pages/tools/schedule/detail?id=${id}` })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadReminders()
|
||||
})
|
||||
|
||||
async function loadReminders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchSchedules({ pageSize: 100 })
|
||||
schedules.value = res.list
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
background: $color-bg-page;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---------- 本周概览 ---------- */
|
||||
.hero-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: linear-gradient(135deg, #1e3a8a, #2563eb);
|
||||
box-shadow: 0 12rpx 30rpx rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.hero-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.hero-num-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
font-size: 28rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-num {
|
||||
font-size: 56rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-illus {
|
||||
flex-shrink: 0;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---------- 周历条 ---------- */
|
||||
.week-card {
|
||||
display: flex;
|
||||
margin-top: 20rpx;
|
||||
padding: 20rpx 8rpx 16rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.week-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.week-name {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.week-date {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
|
||||
&.today {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: $color-primary;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.week-dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
|
||||
&.show {
|
||||
background: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 我的日程 ---------- */
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 32rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.range-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.range-tab {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
|
||||
&.active {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.range-sep {
|
||||
font-size: 22rpx;
|
||||
color: $color-border;
|
||||
}
|
||||
|
||||
.rem-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rem-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 26rpx 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.rem-time {
|
||||
flex-shrink: 0;
|
||||
width: 110rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.rem-day {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.rem-clock {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.rem-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
border-left: 1rpx solid $color-divider;
|
||||
padding-left: 24rpx;
|
||||
}
|
||||
|
||||
.rem-title {
|
||||
font-size: 29rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rem-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.rem-meta-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 60rpx;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user