主要更新uniapp相关功能

This commit is contained in:
hero920103 committed 2026-10-07 00:59:05 +08:00
1 parent aa92ca40a2
commit 5b4dbb2b46
65 files changed
+8532 -1921

No files matched your search

+184 -265
View File
@@ -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
View File
@@ -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 {
+7 -1
View File
@@ -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
View File
@@ -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;
}
}
+7 -1
View File
@@ -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
View File
@@ -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 {
+367
View File
@@ -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>
+230
View File
@@ -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>
+408
View File
@@ -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>
+364
View File
@@ -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>
+346
View File
@@ -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>
+304
View File
@@ -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>
+481
View File
@@ -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>
+189
View File
@@ -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>
+194
View File
@@ -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>
+36 -82
View File
@@ -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 {
+182
View File
@@ -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>
+30 -96
View File
@@ -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>
+17 -52
View File
@@ -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 {
+29 -71
View File
@@ -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 {
+45 -152
View File
@@ -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>
+570 -222
View File
@@ -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>
+441
View File
@@ -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>