优化日程界面

This commit is contained in:
2026-07-15 18:14:17 +08:00
parent 11e0763766
commit bca5170e22
19 changed files with 2805 additions and 1070 deletions
+1
View File
@@ -0,0 +1 @@
{"pid":4360,"startedAt":1784109827988}
+2 -2
View File
@@ -20,8 +20,8 @@
page {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: #f8fafc;
color: #1e293b;
background-color: #f5f7fa;
color: #303133;
box-sizing: border-box;
}
+138
View File
@@ -0,0 +1,138 @@
import { generateId } from '@/api/request.js'
const STORAGE_KEY = 'app_notes'
function readLocal() {
return uni.getStorageSync(STORAGE_KEY) || []
}
function writeLocal(list) {
uni.setStorageSync(STORAGE_KEY, list)
}
function seedIfEmpty() {
const list = readLocal()
if (list.length) return list
const now = Date.now()
const seeded = [
{
id: generateId(),
title: '欢迎使用记事本',
content: '在这里记录灵感、待办或任何想法。支持置顶、搜索与编辑,后续可一键接入云端同步。',
pinned: true,
createdAt: now - 86400000,
updatedAt: now - 3600000
},
{
id: generateId(),
title: '项目会议要点',
content: '1. 确认 Q3 目标\n2. 排期评审\n3. 接口联调时间',
pinned: false,
createdAt: now - 172800000,
updatedAt: now - 7200000
}
]
writeLocal(seeded)
return seeded
}
function sortNotes(list) {
return [...list].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
return (b.updatedAt || 0) - (a.updatedAt || 0)
})
}
/**
* 获取笔记列表
* @param {{ keyword?: string }} params
* @returns {Promise<{ list: Array, total: number }>}
*/
export async function fetchNotes(params = {}) {
// TODO: return request({ url: '/api/notes', data: params })
let list = seedIfEmpty()
const keyword = (params.keyword || '').trim().toLowerCase()
if (keyword) {
list = list.filter(
item =>
item.title.toLowerCase().includes(keyword) ||
item.content.toLowerCase().includes(keyword)
)
}
list = sortNotes(list)
return { list, total: list.length }
}
/**
* 获取单条笔记
* @param {string} id
*/
export async function getNote(id) {
// TODO: return request({ url: `/api/notes/${id}` })
const list = readLocal()
return list.find(item => item.id === id) || null
}
/**
* 创建笔记
* @param {{ title: string, content: string, pinned?: boolean }} data
*/
export async function createNote(data) {
// TODO: return request({ url: '/api/notes', method: 'POST', data })
const now = Date.now()
const note = {
id: generateId(),
title: (data.title || '').trim() || '无标题',
content: (data.content || '').trim(),
pinned: !!data.pinned,
createdAt: now,
updatedAt: now
}
const list = readLocal()
list.unshift(note)
writeLocal(list)
return note
}
/**
* 更新笔记
* @param {string} id
* @param {{ title?: string, content?: string, pinned?: boolean }} data
*/
export async function updateNote(id, data) {
// TODO: return request({ url: `/api/notes/${id}`, method: 'PUT', data })
const list = readLocal()
const index = list.findIndex(item => item.id === id)
if (index === -1) throw new Error('笔记不存在')
const note = {
...list[index],
...data,
title: data.title !== undefined ? (data.title.trim() || '无标题') : list[index].title,
content: data.content !== undefined ? data.content.trim() : list[index].content,
updatedAt: Date.now()
}
list[index] = note
writeLocal(list)
return note
}
/**
* 删除笔记
* @param {string} id
*/
export async function deleteNote(id) {
// TODO: return request({ url: `/api/notes/${id}`, method: 'DELETE' })
const list = readLocal().filter(item => item.id !== id)
writeLocal(list)
return true
}
/**
* 切换置顶
* @param {string} id
*/
export async function toggleNotePin(id) {
const note = await getNote(id)
if (!note) throw new Error('笔记不存在')
return updateNote(id, { pinned: !note.pinned })
}
+24
View File
@@ -0,0 +1,24 @@
/**
* 统一请求封装,接入后端时在此配置 baseURL、token、错误处理。
*
* 示例:
* export function request({ url, method = 'GET', data }) {
* return new Promise((resolve, reject) => {
* uni.request({
* url: BASE_URL + url,
* method,
* data,
* header: { Authorization: 'Bearer ' + getToken() },
* success: (res) => {
* if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data)
* else reject(res)
* },
* fail: reject
* })
* })
* }
*/
export function generateId() {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
}
+242
View File
@@ -0,0 +1,242 @@
import { generateId } from '@/api/request.js'
import { startOfDay, endOfDay } from '@/utils/date.js'
const STORAGE_KEY = 'app_schedules'
const PRIORITY = {
low: { value: 'low', label: '低', color: '#909399' },
medium: { value: 'medium', label: '中', color: '#3c9cff' },
high: { value: 'high', label: '高', color: '#f56c6c' }
}
export const PRIORITY_OPTIONS = Object.values(PRIORITY)
export const REMIND_CHANNEL_OPTIONS = [
{ value: 'sms', label: '短信' },
{ value: 'email', label: '邮件' },
{ value: 'bark', label: 'Bark推送' },
{ value: 'site', label: '站内信' },
{ value: 'app', label: 'APP' }
]
export const REMIND_MINUTES_OPTIONS = [
{ label: '不提前', value: 0 },
{ label: '提前 5 分钟', value: 5 },
{ label: '提前 10 分钟', value: 10 },
{ label: '提前 15 分钟', value: 15 },
{ label: '提前 30 分钟', value: 30 },
{ label: '提前 1 小时', value: 60 },
{ label: '提前 2 小时', value: 120 },
{ label: '提前 1 天', value: 1440 }
]
function readLocal() {
const list = uni.getStorageSync(STORAGE_KEY) || []
return list.map(normalizeSchedule)
}
function writeLocal(list) {
uni.setStorageSync(STORAGE_KEY, list)
}
export function normalizeSchedule(item) {
const content = (item.content || item.title || '').trim()
const title = (item.title || content.split('\n')[0] || '无标题').trim().slice(0, 60)
let remindChannels = item.remindChannels
if (!Array.isArray(remindChannels)) {
remindChannels = item.remindMinutes > 0 ? ['app'] : []
}
return {
...item,
title,
content,
remindChannels,
remindMinutes: item.remindMinutes ?? 15
}
}
function buildTitle(content) {
const line = (content || '').split('\n')[0].trim()
return line.slice(0, 60) || '无标题'
}
function seedIfEmpty() {
const raw = uni.getStorageSync(STORAGE_KEY)
if (raw && raw.length) return raw.map(normalizeSchedule)
const now = Date.now()
const today = new Date()
const seeded = [
{
id: generateId(),
content: '团队周会\n汇报本周进度,讨论下周计划',
datetime: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 10, 0).getTime(),
remindChannels: ['app', 'site'],
remindMinutes: 15,
priority: 'high',
completed: false,
createdAt: now - 86400000,
updatedAt: now - 86400000
},
{
id: generateId(),
content: '提交月报\n整理本月数据并发送给主管',
datetime: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1, 18, 0).getTime(),
remindChannels: ['email', 'site'],
remindMinutes: 30,
priority: 'medium',
completed: false,
createdAt: now - 43200000,
updatedAt: now - 43200000
},
{
id: generateId(),
content: '健身打卡\n有氧运动 30 分钟',
datetime: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1, 19, 30).getTime(),
remindChannels: ['bark', 'app'],
remindMinutes: 10,
priority: 'low',
completed: true,
createdAt: now - 259200000,
updatedAt: now - 86400000
}
].map(normalizeSchedule)
writeLocal(seeded)
return seeded
}
function sortSchedules(list) {
return [...list].sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1
return (a.datetime || 0) - (b.datetime || 0)
})
}
/**
* 获取日程列表
*/
export async function fetchSchedules(params = {}) {
// TODO: return request({ url: '/api/schedules', data: params })
let list = seedIfEmpty()
const keyword = (params.keyword || '').trim().toLowerCase()
const status = params.status || 'all'
if (keyword) {
list = list.filter(item => {
const text = `${item.title} ${item.content}`.toLowerCase()
return text.includes(keyword)
})
}
if (status === 'pending') list = list.filter(item => !item.completed)
if (status === 'done') list = list.filter(item => item.completed)
if (params.date) {
const dayStart = startOfDay(params.date).getTime()
const dayEnd = endOfDay(params.date).getTime()
list = list.filter(item => item.datetime >= dayStart && item.datetime <= dayEnd)
}
list = sortSchedules(list)
return { list, total: list.length }
}
/**
* 获取单条日程
*/
export async function getSchedule(id) {
// TODO: return request({ url: `/api/schedules/${id}` })
const list = readLocal()
const item = list.find(entry => entry.id === id)
return item ? normalizeSchedule(item) : null
}
/**
* 创建日程
* @param {{ content: string, datetime: number, remindChannels?: string[], remindMinutes?: number }} data
*/
export async function createSchedule(data) {
// TODO: return request({ url: '/api/schedules', method: 'POST', data })
const now = Date.now()
const content = (data.content || '').trim()
const schedule = normalizeSchedule({
id: generateId(),
title: buildTitle(content),
content,
datetime: data.datetime,
remindChannels: data.remindChannels || [],
remindMinutes: data.remindMinutes ?? 15,
priority: data.priority || 'medium',
completed: false,
createdAt: now,
updatedAt: now
})
const list = readLocal()
list.push(schedule)
writeLocal(list)
return schedule
}
/**
* 更新日程
*/
export async function updateSchedule(id, data) {
// TODO: return request({ url: `/api/schedules/${id}`, method: 'PUT', data })
const list = readLocal()
const index = list.findIndex(item => item.id === id)
if (index === -1) throw new Error('日程不存在')
const prev = list[index]
const content = data.content !== undefined ? data.content.trim() : prev.content
const schedule = normalizeSchedule({
...prev,
...data,
title: data.content !== undefined ? buildTitle(content) : prev.title,
content,
updatedAt: Date.now()
})
list[index] = schedule
writeLocal(list)
return schedule
}
/**
* 删除日程
*/
export async function deleteSchedule(id) {
// TODO: return request({ url: `/api/schedules/${id}`, method: 'DELETE' })
const list = readLocal().filter(item => item.id !== id)
writeLocal(list)
return true
}
/**
* 切换完成状态
*/
export async function toggleScheduleComplete(id) {
const item = await getSchedule(id)
if (!item) throw new Error('日程不存在')
return updateSchedule(id, { completed: !item.completed })
}
export function getPriorityMeta(value) {
return PRIORITY[value] || PRIORITY.medium
}
export function getRemindChannelLabel(value) {
return REMIND_CHANNEL_OPTIONS.find(item => item.value === value)?.label || value
}
export function getRemindChannelLabels(channels = []) {
if (!channels.length) return '未设置'
return channels.map(getRemindChannelLabel).join('、')
}
export function getRemindMinutesLabel(minutes) {
const item = REMIND_MINUTES_OPTIONS.find(option => option.value === minutes)
return item?.label || (minutes ? `提前 ${minutes} 分钟` : '不提前')
}
export function getScheduleSummary(item) {
const text = (item?.content || item?.title || '').trim()
const line = text.split('\n')[0]
return line || '无内容'
}
+13 -105
View File
@@ -1,179 +1,87 @@
<template>
<view class="app-tabbar">
<view class="tabbar-pill">
<view class="tabbar-inner">
<view
v-for="tab in tabs"
:key="tab.name"
class="tab-item"
:class="{ active: current === tab.name }"
@tap="onChange(tab.name)"
>
<FaIcon
:name="tab.icon"
:color="current === tab.name ? '#3c9cff' : '#9acafc'"
:size="20"
:color="current === tab.name ? '#3c9cff' : '#909399'"
:size="22"
/>
<text class="tab-text">{{ tab.text }}</text>
</view>
</view>
<view class="safe-area" />
</view>
</template>
<script setup>
const props = defineProps({
current: {
type: String,
default: 'dashboard'
}
})
const tabs = [
{ name: 'dashboard', text: '仪表盘', icon: 'table-cells' },
{ name: 'features', text: '功能', icon: 'grip' },
{ name: 'profile', text: '我的', icon: 'user' }
]
const routes = {
dashboard: '/pages/dashboard/dashboard',
features: '/pages/features/features',
profile: '/pages/profile/profile'
}
function onChange(name) {
if (name === props.current) return
uni.redirectTo({ url: routes[name] })
}
</script>
<style scoped>
.app-tabbar {
flex-shrink: 0;
padding: 16rpx 32rpx 0;
background: #ffffff;
border-top: 1rpx solid #ebeef5;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.tabbar-pill {
.tabbar-inner {
display: flex;
align-items: center;
justify-content: space-around;
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 999rpx;
padding: 16rpx 12rpx;
padding: 12rpx 0 8rpx;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
gap: 4rpx;
padding: 8rpx 0;
&.active .tab-text {
color: #3c9cff;
font-weight: 500;
}
}
.tab-text {
font-size: 22rpx;
color: #9acafc;
color: #909399;
}
.tab-item.active .tab-text {
color: #3c9cff;
font-weight: 500;
}
.safe-area {
height: constant(safe-area-inset-bottom);
height: env(safe-area-inset-bottom);
}
</style>
+35
View File
@@ -36,6 +36,41 @@
"navigationStyle": "custom",
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/tools/notepad/index",
"style": {
"navigationBarTitleText": "记事本",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/tools/notepad/edit",
"style": {
"navigationBarTitleText": "新建笔记",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/tools/schedule/index",
"style": {
"navigationBarTitleText": "日程提醒",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/tools/schedule/detail",
"style": {
"navigationBarTitleText": "日程详情",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/tools/schedule/edit",
"style": {
"navigationBarTitleText": "新建日程",
"navigationBarBackgroundColor": "#F8FAFC"
}
}
],
"globalStyle": {
+91 -94
View File
@@ -11,7 +11,7 @@
</view>
</view>
<view class="search-bar">
<FaIcon name="magnifying-glass" color="#9acafc" :size="18" />
<FaIcon name="magnifying-glass" color="#909399" :size="16" />
<text class="search-text">搜索功能数据...</text>
</view>
</view>
@@ -19,17 +19,20 @@
<view class="main-scroll">
<view class="stats-grid">
<view class="stat-card" v-for="item in stats" :key="item.label">
<text class="stat-value">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
<text class="stat-trend" :class="item.up ? 'up' : 'down'">
{{ item.up ? '↑' : '↓' }} {{ item.trend }}
</text>
<text class="stat-value">{{ item.value }}</text>
<view class="stat-trend" :class="item.up ? 'up' : 'down'">
<FaIcon :name="item.up ? 'arrow-trend-up' : 'arrow-trend-down'" :color="item.up ? '#3c9cff' : '#909399'" :size="12" />
<text>{{ item.trend }}</text>
</view>
</view>
</view>
<view class="section">
<text class="section-title">快捷入口</text>
<view class="quick-grid">
<view class="section-header">
<text class="section-title">快捷入口</text>
</view>
<view class="quick-card">
<view
class="quick-item"
v-for="item in quickActions"
@@ -37,7 +40,7 @@
@tap="onQuickTap(item)"
>
<view class="quick-icon">
<FaIcon :name="item.icon" color="#3c9cff" :size="22" />
<FaIcon :name="item.icon" color="#3c9cff" :size="20" />
</view>
<text class="quick-name">{{ item.name }}</text>
</view>
@@ -51,11 +54,14 @@
</view>
<view class="activity-card">
<view class="activity-item" v-for="(item, index) in activities" :key="index">
<view class="activity-dot" />
<view class="activity-icon">
<FaIcon name="circle" color="#3c9cff" :size="8" />
</view>
<view class="activity-body">
<text class="activity-title">{{ item.title }}</text>
<text class="activity-time">{{ item.time }}</text>
</view>
<FaIcon name="chevron-right" color="#c0c4cc" :size="12" />
</view>
</view>
</view>
@@ -121,149 +127,145 @@ function onQuickTap(item) {
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
height: 100vh;
background: #ffffff;
display: flex;
flex-direction: column;
overflow: hidden;
@include page-shell;
}
.header {
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 24rpx) 40rpx 32rpx;
background: #ffffff;
padding: calc(var(--status-bar-height, 44px) + 20rpx) $page-padding-x 24rpx;
background: $color-card;
box-shadow: $shadow-header;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
margin-bottom: 28rpx;
}
.greeting {
display: block;
font-size: 26rpx;
color: $u-primary-disabled;
color: $color-text-muted;
}
.username {
display: block;
font-size: 40rpx;
font-weight: 600;
color: $u-primary;
margin-top: 6rpx;
color: $color-text;
margin-top: 4rpx;
}
.avatar {
width: 80rpx;
height: 80rpx;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: #ffffff;
border: 2rpx solid #e8f0f8;
background: $color-primary-bg;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 32rpx;
font-size: 34rpx;
font-weight: 600;
color: $u-primary;
color: $color-primary;
}
.search-bar {
display: flex;
align-items: center;
gap: 16rpx;
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
padding: 22rpx 28rpx;
background: $color-bg-page;
border-radius: $radius-md;
padding: 20rpx 24rpx;
}
.search-text {
font-size: 26rpx;
color: $u-primary-disabled;
font-size: 28rpx;
color: $color-text-muted;
}
.main-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 8rpx 40rpx 0;
@include scroll-body;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
margin-bottom: 48rpx;
margin-bottom: 32rpx;
}
.stat-card {
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
padding: 28rpx;
}
.stat-value {
display: block;
font-size: 36rpx;
font-weight: 600;
color: $u-primary;
@include card;
padding: 28rpx 24rpx;
}
.stat-label {
display: block;
font-size: 24rpx;
color: $u-primary-disabled;
margin-top: 6rpx;
color: $color-text-muted;
}
.stat-value {
display: block;
font-size: 40rpx;
font-weight: 600;
color: $color-text;
margin-top: 8rpx;
}
.stat-trend {
display: block;
margin-top: 12rpx;
display: inline-flex;
align-items: center;
gap: 6rpx;
margin-top: 16rpx;
padding: 4rpx 12rpx;
border-radius: $radius-sm;
font-size: 22rpx;
&.up {
color: $u-primary;
background: $color-primary-bg;
color: $color-primary;
}
&.down {
color: $u-primary-disabled;
background: $color-divider;
color: $color-text-muted;
}
}
.section {
margin-bottom: 48rpx;
margin-bottom: 32rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: $u-primary;
@include section-title;
}
.section-more {
font-size: 24rpx;
color: $u-primary-disabled;
font-size: 26rpx;
color: $color-primary;
}
.quick-grid {
.quick-card {
@include card;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
padding: 32rpx 16rpx;
}
.quick-item {
@@ -271,72 +273,67 @@ function onQuickTap(item) {
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.quick-icon {
width: 96rpx;
height: 96rpx;
border-radius: 24rpx;
background: #ffffff;
border: 2rpx solid #e8f0f8;
display: flex;
align-items: center;
justify-content: center;
&:active {
border-color: $u-primary-disabled;
opacity: 0.7;
}
}
.quick-icon {
@include icon-box(88rpx);
border-radius: $radius-lg;
}
.quick-name {
font-size: 22rpx;
color: $u-primary-disabled;
font-size: 24rpx;
color: $color-text-secondary;
}
.activity-card {
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
@include card;
overflow: hidden;
}
.activity-item {
display: flex;
align-items: center;
padding: 28rpx;
gap: 20rpx;
padding: 28rpx 24rpx;
gap: 16rpx;
& + & {
border-top: 2rpx solid #f0f6fb;
border-top: 1rpx solid $color-divider;
}
&:active {
background: $color-bg-page;
}
}
.activity-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: $u-primary;
flex-shrink: 0;
.activity-icon {
width: 32rpx;
display: flex;
justify-content: center;
}
.activity-body {
flex: 1;
min-width: 0;
}
.activity-title {
display: block;
font-size: 28rpx;
color: $u-primary;
color: $color-text;
}
.activity-time {
display: block;
font-size: 22rpx;
color: $u-primary-disabled;
font-size: 24rpx;
color: $color-text-muted;
margin-top: 6rpx;
}
.bottom-space {
height: 40rpx;
height: 24rpx;
}
</style>
+236 -383
View File
@@ -1,451 +1,304 @@
<template>
<view class="page">
<view class="page-header">
<text class="page-title">功能中心</text>
<text class="page-desc">探索更多精彩功能</text>
<view class="search-wrap">
<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"
/>
<view v-if="keyword" class="search-clear" @tap="keyword = ''">
<FaIcon name="circle-xmark" color="#c0c4cc" :size="16" />
</view>
</view>
</view>
<view class="main-scroll">
<view class="feature-banner" @tap="onBannerTap">
<view class="banner-content">
<text class="banner-title">新功能上线</text>
<text class="banner-desc">AI 智能助手效率翻倍</text>
<view class="body">
<view class="sidebar">
<view
v-for="(cat, index) in categories"
:key="cat.id"
class="category-item"
:class="{ active: activeIndex === index }"
@tap="selectCategory(index)"
>
<text class="category-text">{{ cat.title }}</text>
</view>
<view class="banner-btn">立即体验</view>
</view>
<view class="category" v-for="cat in categories" :key="cat.title">
<text class="category-title">{{ cat.title }}</text>
<view class="feature-grid">
<view
class="feature-card"
v-for="item in cat.items"
:key="item.name"
@tap="onFeatureTap(item)"
>
<view class="feature-icon">
<FaIcon :name="item.icon" color="#3c9cff" :size="22" />
</view>
<text class="feature-name">{{ item.name }}</text>
<text class="feature-desc">{{ item.desc }}</text>
</view>
<view class="content">
<view v-if="displayItems.length === 0" class="empty">
<FaIcon name="magnifying-glass" color="#c0c4cc" :size="32" />
<text class="empty-text">未找到相关功能</text>
</view>
<view
v-for="item in displayItems"
:key="item.id"
class="item-card"
:class="{ active: activeItemId === item.id }"
@tap="onItemTap(item)"
>
<text class="item-name">{{ item.name }}</text>
</view>
</view>
<view class="bottom-space" />
</view>
<AppTabbar current="features" />
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { isLoggedIn } from '@/utils/auth.js'
import AppTabbar from '@/components/AppTabbar.vue'
const keyword = ref('')
const activeIndex = ref(0)
const activeItemId = ref('')
const routeMap = {
notepad: '/pages/tools/notepad/index',
schedule: '/pages/tools/schedule/index'
}
const categories = ref([
{
id: 'handy',
title: '便捷工具',
items: [
{ id: 'notepad', name: '记事本', route: routeMap.notepad },
{ id: 'schedule', name: '日程提醒', route: routeMap.schedule }
]
},
{
id: 'tools',
title: '常用工具',
items: [
{ name: '扫一扫', desc: '快速识别', icon: 'qrcode' },
{ name: '收付款', desc: '便捷支付', icon: 'coins' },
{ name: '卡包', desc: '优惠券', icon: 'ticket' },
{ name: '出行', desc: '交通服务', icon: 'car' }
{ id: 't1', name: '扫一扫' },
{ id: 't2', name: '收付款' },
{ id: 't3', name: '卡包管理' },
{ id: 't4', name: '出行服务' },
{ id: 't5', name: '二维码生成' },
{ id: 't6', name: '发票助手' }
]
},
{
id: 'life',
title: '生活服务',
items: [
{ name: '外卖', desc: '美食到家', icon: 'bag-shopping' },
{ name: '电影', desc: '在线购票', icon: 'film' },
{ name: '酒店', desc: '预订住宿', icon: 'hotel' },
{ name: '更多', desc: '发现更多', icon: 'ellipsis' }
{ id: 'l1', name: '外卖订餐' },
{ id: 'l2', name: '电影购票' },
{ id: 'l3', name: '酒店预订' },
{ id: 'l4', name: '快递查询' },
{ id: 'l5', name: '家政预约' },
{ id: 'l6', name: '更多服务' }
]
},
{
id: 'office',
title: '办公效率',
items: [
{ name: '文档', desc: '在线协作', icon: 'file-lines' },
{ name: '日历', desc: '日程管理', icon: 'calendar-days' },
{ name: '云盘', desc: '文件存储', icon: 'folder' },
{ name: '会议', desc: '视频会议', icon: 'video' }
{ id: 'o1', name: '文档协作' },
{ id: 'o2', name: '日程管理' },
{ id: 'o3', name: '云盘存储' },
{ id: 'o4', name: '视频会议' },
{ id: 'o5', name: '任务看板' },
{ id: 'o6', name: '审批流程' }
]
},
{
id: 'data',
title: '数据报表',
items: [
{ id: 'd1', name: '销售统计' },
{ id: 'd2', name: '用户分析' },
{ id: 'd3', name: '访问趋势' },
{ id: 'd4', name: '转化报表' },
{ id: 'd5', name: '导出数据' }
]
},
{
id: 'system',
title: '系统设置',
items: [
{ id: 's1', name: '账号管理' },
{ id: 's2', name: '权限配置' },
{ id: 's3', name: '消息通知' },
{ id: 's4', name: '安全中心' },
{ id: 's5', name: '关于系统' }
]
}
])
const currentCategory = computed(() => categories.value[activeIndex.value])
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
}
const displayItems = computed(() => {
const items = currentCategory.value?.items || []
const q = keyword.value.trim().toLowerCase()
if (!q) return items
return items.filter(item => item.name.toLowerCase().includes(q))
})
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
}
})
function onBannerTap() {
uni.showToast({ title: 'AI 智能助手', icon: 'none' })
function selectCategory(index) {
activeIndex.value = index
activeItemId.value = ''
}
function onFeatureTap(item) {
function onItemTap(item) {
activeItemId.value = item.id
if (item.route) {
uni.navigateTo({ url: item.route })
return
}
uni.showToast({ title: item.name, icon: 'none' })
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
height: 100vh;
background: #ffffff;
display: flex;
flex-direction: column;
overflow: hidden;
@include page-shell;
}
.page-header {
.search-wrap {
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 24rpx) 40rpx 24rpx;
padding: calc(var(--status-bar-height, 44px) + 16rpx) 24rpx 20rpx;
background: $color-card;
}
.page-title {
display: block;
font-size: 40rpx;
font-weight: 600;
color: $u-primary;
.search-bar {
display: flex;
align-items: center;
gap: 12rpx;
height: 72rpx;
padding: 0 24rpx;
background: $color-bg-page;
border-radius: $radius-full;
}
.page-desc {
display: block;
font-size: 26rpx;
color: $u-primary-disabled;
margin-top: 6rpx;
}
.main-scroll {
.search-input {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 40rpx;
}
.feature-banner {
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
padding: 32rpx 28rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 48rpx;
&:active {
border-color: $u-primary-disabled;
}
}
.banner-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $u-primary;
}
.banner-desc {
display: block;
font-size: 24rpx;
color: $u-primary-disabled;
margin-top: 8rpx;
}
.banner-btn {
padding: 14rpx 28rpx;
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
font-size: 24rpx;
color: $u-primary;
font-weight: 500;
}
.category {
margin-bottom: 48rpx;
}
.category-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $u-primary;
margin-bottom: 24rpx;
}
.feature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.feature-card {
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
padding: 28rpx;
&:active {
border-color: $u-primary-disabled;
}
}
.feature-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
background: #ffffff;
border: 2rpx solid #e8f0f8;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.feature-name {
display: block;
height: 72rpx;
font-size: 28rpx;
font-weight: 600;
color: $u-primary;
color: $color-text;
}
.feature-desc {
display: block;
font-size: 22rpx;
color: $u-primary-disabled;
margin-top: 4rpx;
.search-placeholder {
color: $color-text-muted;
font-size: 28rpx;
}
.bottom-space {
height: 40rpx;
.search-clear {
display: flex;
align-items: center;
padding: 8rpx;
}
.body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
background: $color-card;
}
.sidebar {
width: 200rpx;
flex-shrink: 0;
background: $color-bg-page;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.category-item {
position: relative;
padding: 32rpx 16rpx;
text-align: center;
&.active {
background: $color-card;
.category-text {
color: $color-primary;
font-weight: 500;
}
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 36rpx;
background: $color-primary;
border-radius: 0 4rpx 4rpx 0;
}
}
}
.category-text {
font-size: 26rpx;
color: $color-text-secondary;
line-height: 1.4;
}
.content {
flex: 1;
min-width: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 20rpx 24rpx;
background: $color-card;
}
.item-card {
padding: 28rpx 24rpx;
margin-bottom: 16rpx;
background: $color-bg-page;
border-radius: $radius-md;
&.active {
background: $color-primary-bg;
.item-name {
color: $color-primary;
}
}
&:active {
opacity: 0.85;
}
}
.item-name {
font-size: 28rpx;
color: $color-text;
line-height: 1.4;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
gap: 20rpx;
}
.empty-text {
font-size: 26rpx;
color: $color-text-muted;
}
</style>
+101 -79
View File
@@ -3,6 +3,9 @@
<view class="page-inner">
<!-- 品牌区 -->
<view class="header">
<view class="logo">
<text class="logo-char"></text>
</view>
<text class="title">欢迎回来</text>
<text class="subtitle">登录云泽开启你的旅程</text>
</view>
@@ -22,7 +25,8 @@
</view>
<!-- 表单 -->
<view class="form">
<view class="form-card">
<view class="form">
<template v-if="loginType === 'phone'">
<view class="field">
<u-input
@@ -31,7 +35,7 @@
type="number"
maxlength="11"
border="none"
color="#3c9cff"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
>
@@ -47,7 +51,7 @@
type="number"
maxlength="6"
border="none"
color="#3c9cff"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
@@ -65,7 +69,7 @@
v-model="username"
placeholder="请输入账号"
border="none"
color="#3c9cff"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
@@ -76,7 +80,7 @@
placeholder="请输入密码"
type="password"
border="none"
color="#3c9cff"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
@@ -85,6 +89,7 @@
<view class="btn-primary" @tap="handleLogin"> </view>
<view class="btn-ghost" @tap="handleTestLogin">测试登录</view>
</view>
</view>
<!-- 协议 -->
@@ -139,7 +144,7 @@ const inputStyle = {
fontSize: '28rpx'
}
const placeholderStyle = 'color: #9acafc; font-size: 28rpx'
const placeholderStyle = 'color: #909399; font-size: 28rpx'
onMounted(() => {
if (isLoggedIn()) {
@@ -217,85 +222,85 @@ function socialLogin(type) {
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.login-page {
min-height: 100vh;
background: #ffffff;
background: $color-bg-page;
}
.page-inner {
padding: 0 56rpx;
padding: 0 48rpx;
padding-top: calc(var(--status-bar-height, 44px) + 80rpx);
}
/* ---- 品牌 ---- */
.header {
margin-bottom: 72rpx;
margin-bottom: 64rpx;
}
.logo {
width: 88rpx;
height: 88rpx;
border-radius: 22rpx;
background: #ffffff;
border: 2rpx solid #e8f0f8;
width: 96rpx;
height: 96rpx;
border-radius: $radius-lg;
background: $color-primary-bg;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 36rpx;
margin-bottom: 32rpx;
}
.logo-char {
font-size: 44rpx;
font-weight: 600;
color: $u-primary;
color: $color-primary;
}
.title {
display: block;
font-size: 52rpx;
font-size: 48rpx;
font-weight: 600;
color: $u-primary;
letter-spacing: 2rpx;
color: $color-text;
}
.subtitle {
display: block;
font-size: 26rpx;
color: $u-primary-disabled;
font-size: 28rpx;
color: $color-text-muted;
margin-top: 12rpx;
}
/* ---- Tab ---- */
.tabs {
display: flex;
gap: 48rpx;
margin-bottom: 48rpx;
background: $color-card;
border-radius: $radius-md;
padding: 6rpx;
margin-bottom: 32rpx;
box-shadow: $shadow-card;
}
.tab {
font-size: 30rpx;
color: $u-primary-disabled;
padding-bottom: 12rpx;
position: relative;
flex: 1;
text-align: center;
font-size: 28rpx;
color: $color-text-secondary;
padding: 18rpx 0;
border-radius: $radius-sm;
&.active {
color: $u-primary;
color: $color-primary;
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4rpx;
background: $u-primary;
border-radius: 2rpx;
}
background: $color-primary-bg;
}
}
/* ---- 表单 ---- */
.form-card {
@include card;
padding: 32rpx 28rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.form {
display: flex;
flex-direction: column;
@@ -303,10 +308,9 @@ function socialLogin(type) {
}
.field {
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
padding: 0 28rpx;
background: $color-bg-page;
border-radius: $radius-md;
padding: 0 24rpx;
overflow: hidden;
}
@@ -317,71 +321,70 @@ function socialLogin(type) {
.prefix {
font-size: 28rpx;
color: $u-primary;
color: $color-text;
padding-right: 20rpx;
margin-right: 20rpx;
border-right: 1rpx solid $u-primary-disabled;
border-right: 1rpx solid $color-border;
}
.code-link {
flex-shrink: 0;
font-size: 26rpx;
color: $u-primary;
color: $color-primary;
padding-left: 16rpx;
white-space: nowrap;
&.disabled {
color: $u-primary-disabled;
color: $color-text-muted;
}
}
.btn-primary {
height: 96rpx;
background: $u-primary;
border-radius: 16rpx;
background: $color-primary;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 600;
color: #fff;
letter-spacing: 6rpx;
margin-top: 16rpx;
margin-top: 8rpx;
&:active {
background: $u-primary-dark;
background: $color-primary-dark;
}
}
.btn-ghost {
height: 96rpx;
background: #ffffff;
border: 2rpx solid $u-primary;
border-radius: 16rpx;
background: $color-card;
border: 1rpx solid $color-border;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: $u-primary;
color: $color-text-secondary;
&:active {
background: #f8fbff;
background: $color-bg-page;
}
}
/* ---- 协议 ---- */
.agreement {
display: flex;
align-items: flex-start;
gap: 12rpx;
margin-top: 36rpx;
margin-top: 32rpx;
}
.agree-dot {
width: 30rpx;
height: 30rpx;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 2rpx solid $u-primary-disabled;
border: 1rpx solid $color-border;
background: $color-card;
display: flex;
align-items: center;
justify-content: center;
@@ -389,8 +392,8 @@ function socialLogin(type) {
margin-top: 2rpx;
&.on {
background: $u-primary;
border-color: $u-primary;
background: $color-primary;
border-color: $color-primary;
}
}
@@ -400,18 +403,17 @@ function socialLogin(type) {
}
.agree-text {
font-size: 22rpx;
color: $u-primary-disabled;
font-size: 24rpx;
color: $color-text-muted;
line-height: 1.7;
}
.agree-link {
color: $u-primary;
color: $color-primary;
}
/* ---- 第三方 ---- */
.oauth {
margin-top: 80rpx;
margin-top: 64rpx;
display: flex;
flex-direction: column;
align-items: center;
@@ -421,26 +423,46 @@ function socialLogin(type) {
.oauth-label {
font-size: 24rpx;
color: $u-primary-disabled;
color: $color-text-muted;
position: relative;
padding: 0 32rpx;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
width: 80rpx;
height: 1rpx;
background: $color-border;
}
&::before {
right: 100%;
}
&::after {
left: 100%;
}
}
.oauth-icons {
display: flex;
gap: 40rpx;
gap: 48rpx;
}
.oauth-btn {
width: 80rpx;
height: 80rpx;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: #ffffff;
border: 2rpx solid #e8f0f8;
background: $color-card;
box-shadow: $shadow-card;
display: flex;
align-items: center;
justify-content: center;
&:active {
border-color: $u-primary-disabled;
background: $color-bg-page;
}
}
</style>
+47 -390
View File
@@ -1,649 +1,306 @@
<template>
<view class="page">
<view class="profile-header">
<view class="profile-info">
<view class="profile-avatar">
<text class="avatar-text">{{ avatarText }}</text>
</view>
<view class="profile-detail">
<text class="profile-name">{{ user?.nickname || '云泽用户' }}</text>
<text class="profile-id">ID: {{ userId }}</text>
</view>
<view class="profile-edit" @tap="onEdit">
<FaIcon name="pen-to-square" color="#3c9cff" :size="18" />
<FaIcon name="pen-to-square" color="#ffffff" :size="16" />
</view>
</view>
<view class="profile-stats">
<view class="profile-stat" v-for="item in profileStats" :key="item.label">
<text class="stat-num">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="main-scroll">
<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="#3c9cff" :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="#9acafc" :size="14" />
<FaIcon name="chevron-right" color="#c0c4cc" :size="12" />
</view>
</view>
</view>
<view class="logout-btn" @tap="handleLogout">
<text class="logout-text">退出登录</text>
</view>
<view class="bottom-space" />
</view>
<AppTabbar current="profile" />
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getUser, logout, isLoggedIn } from '@/utils/auth.js'
import AppTabbar from '@/components/AppTabbar.vue'
const user = ref(null)
const avatarText = computed(() => {
const name = user.value?.nickname || '云'
return name.charAt(0).toUpperCase()
})
const userId = computed(() => {
return user.value?.phone
? user.value.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
: '10086'
})
const profileStats = ref([
{ label: '关注', value: '128' },
{ label: '粉丝', value: '256' },
{ label: '获赞', value: '1.2k' }
])
const menuGroups = ref([
[
{ title: '我的订单', icon: 'clipboard-list' },
{ title: '我的收藏', icon: 'star' },
{ title: '浏览历史', icon: 'clock-rotate-left' }
],
[
{ title: '消息通知', icon: 'bell', badge: '3' },
{ title: '账号安全', icon: 'lock' },
{ title: '隐私设置', icon: 'eye' }
],
[
{ title: '帮助中心', icon: 'circle-question' },
{ title: '关于我们', icon: 'circle-info' },
{ title: '设置', icon: 'gear' }
]
])
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
user.value = getUser()
})
function onEdit() {
uni.showToast({ title: '编辑资料', icon: 'none' })
}
function onMenuTap(item) {
uni.showToast({ title: item.title, icon: 'none' })
}
function handleLogout() {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success(res) {
if (res.confirm) {
logout()
uni.reLaunch({ url: '/pages/login/login' })
}
}
})
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
height: 100vh;
background: #ffffff;
display: flex;
flex-direction: column;
overflow: hidden;
@include page-shell;
}
.profile-header {
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 24rpx) 40rpx 32rpx;
background: #ffffff;
padding: calc(var(--status-bar-height, 44px) + 20rpx) $page-padding-x 36rpx;
background: $color-primary;
}
.profile-info {
display: flex;
align-items: center;
gap: 24rpx;
}
.profile-avatar {
width: 100rpx;
height: 100rpx;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: #ffffff;
border: 2rpx solid #e8f0f8;
background: rgba(255, 255, 255, 0.2);
border: 4rpx solid rgba(255, 255, 255, 0.35);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
font-size: 40rpx;
font-size: 44rpx;
font-weight: 600;
color: $u-primary;
color: #ffffff;
}
.profile-detail {
flex: 1;
min-width: 0;
}
.profile-name {
display: block;
font-size: 36rpx;
font-weight: 600;
color: $u-primary;
color: #ffffff;
}
.profile-id {
display: block;
font-size: 24rpx;
color: $u-primary-disabled;
color: rgba(255, 255, 255, 0.75);
margin-top: 6rpx;
}
.profile-edit {
width: 64rpx;
height: 64rpx;
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: #ffffff;
border: 2rpx solid #e8f0f8;
background: rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
&:active {
border-color: $u-primary-disabled;
background: rgba(255, 255, 255, 0.25);
}
}
.profile-stats {
display: flex;
justify-content: space-around;
margin-top: 32rpx;
padding: 28rpx 0;
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
margin-top: 36rpx;
padding-top: 28rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.2);
}
.profile-stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-num {
font-size: 32rpx;
font-size: 36rpx;
font-weight: 600;
color: $u-primary;
color: #ffffff;
}
.stat-label {
font-size: 22rpx;
color: $u-primary-disabled;
margin-top: 4rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
margin-top: 6rpx;
}
.main-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 8rpx 40rpx 0;
@include scroll-body;
margin-top: -20rpx;
}
.menu-group {
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
@include card;
margin-bottom: 24rpx;
overflow: hidden;
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 24rpx;
padding: 28rpx;
border-bottom: 2rpx solid #f0f6fb;
&:last-child {
border-bottom: none;
& + & {
border-top: 1rpx solid $color-divider;
}
&:active {
background: #f8fafc;
background: $color-bg-page;
}
}
.menu-left {
display: flex;
align-items: center;
gap: 20rpx;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
background: #ffffff;
border: 2rpx solid #e8f0f8;
display: flex;
align-items: center;
justify-content: center;
@include icon-box(64rpx);
}
.menu-title {
font-size: 28rpx;
color: $u-primary;
color: $color-text;
}
.menu-right {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.menu-badge {
font-size: 20rpx;
color: #ffffff;
background: $u-primary;
padding: 2rpx 12rpx;
border-radius: 999rpx;
background: $color-primary;
padding: 4rpx 12rpx;
border-radius: $radius-full;
min-width: 32rpx;
text-align: center;
line-height: 1.4;
}
.logout-btn {
margin-top: 8rpx;
@include card;
height: 96rpx;
background: #ffffff;
border: 2rpx solid #e8f0f8;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
border-color: $u-primary-disabled;
background: #f8fafc;
background: $color-bg-page;
}
}
.logout-text {
font-size: 30rpx;
color: $u-primary-disabled;
font-weight: 500;
color: $color-text-secondary;
}
.bottom-space {
height: 40rpx;
height: 24rpx;
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<template>
<view class="page">
<view class="form-card">
<view class="field">
<text class="field-label">标题</text>
<input
class="field-input title-input"
v-model="form.title"
type="text"
placeholder="输入标题"
placeholder-class="placeholder"
maxlength="80"
/>
</view>
<view class="field content-field">
<text class="field-label">内容</text>
<textarea
class="field-textarea"
v-model="form.content"
placeholder="记录你的想法..."
placeholder-class="placeholder"
:auto-height="false"
maxlength="5000"
:show-confirm-bar="false"
/>
<text class="char-count">{{ form.content.length }}/5000</text>
</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>
</view>
<view v-if="noteId && meta.updatedAt" class="meta">
<text>最近更新{{ formatDate(meta.updatedAt, true) }}</text>
</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>
</view>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getNote, createNote, updateNote, deleteNote } from '@/api/note.js'
import { formatDate } from '@/utils/date.js'
const noteId = ref('')
const saving = ref(false)
const form = reactive({
title: '',
content: '',
pinned: false
})
const meta = reactive({
updatedAt: 0
})
onLoad(async (query) => {
if (query?.id) {
noteId.value = query.id
uni.setNavigationBarTitle({ title: '编辑笔记' })
await loadNote(query.id)
} else {
uni.setNavigationBarTitle({ title: '新建笔记' })
}
})
async function loadNote(id) {
try {
const note = await getNote(id)
if (!note) {
uni.showToast({ title: '笔记不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
return
}
form.title = note.title
form.content = note.content
form.pinned = !!note.pinned
meta.updatedAt = note.updatedAt
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
async function onSave() {
if (saving.value) return
if (!form.title.trim() && !form.content.trim()) {
uni.showToast({ title: '请输入标题或内容', icon: 'none' })
return
}
saving.value = true
try {
if (noteId.value) {
await updateNote(noteId.value, {
title: form.title,
content: form.content,
pinned: form.pinned
})
} else {
await createNote({
title: form.title,
content: form.content,
pinned: form.pinned
})
}
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
function onDelete() {
uni.showModal({
title: '删除笔记',
content: '删除后无法恢复,确定继续吗?',
success: async (res) => {
if (!res.confirm) return
try {
await deleteNote(noteId.value)
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} 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;
padding: 24rpx $page-padding-x;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
.form-card {
@include card;
padding: 8rpx 0;
}
.field {
padding: 24rpx 28rpx;
& + & {
border-top: 1rpx solid $color-divider;
}
}
.field-label {
display: block;
font-size: 24rpx;
color: $color-text-muted;
margin-bottom: 12rpx;
}
.field-input,
.field-textarea {
width: 100%;
font-size: 30rpx;
color: $color-text;
}
.title-input {
font-weight: 600;
}
.content-field {
position: relative;
}
.field-textarea {
height: 480rpx;
line-height: 1.7;
}
.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;
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-top: 20rpx;
padding: 0 8rpx;
font-size: 24rpx;
color: $color-text-muted;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 20rpx;
padding: 20rpx $page-padding-x;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: $color-card;
border-top: 1rpx solid $color-border;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.btn-primary,
.btn-danger {
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>
+332
View File
@@ -0,0 +1,332 @@
<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>
<view class="stats-row">
<text class="stats-text"> {{ total }} </text>
<text v-if="pinnedCount" class="stats-text">置顶 {{ pinnedCount }} </text>
</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="notes.length === 0" class="state-box">
<FaIcon name="note-sticky" color="#c0c4cc" :size="40" />
<text class="state-text">{{ keyword ? '未找到相关笔记' : '暂无笔记,点击右下角新建' }}</text>
</view>
<view v-else class="note-list">
<view
v-for="item in notes"
:key="item.id"
class="note-card"
@tap="openEdit(item.id)"
>
<view class="note-head">
<view class="note-title-row">
<text v-if="item.pinned" class="pin-tag">置顶</text>
<text class="note-title">{{ item.title }}</text>
</view>
<text class="note-time">{{ formatRelativeTime(item.updatedAt) }}</text>
</view>
<text class="note-preview">{{ preview(item.content) }}</text>
<view class="note-actions" @tap.stop>
<view class="action-btn" @tap="onTogglePin(item)">
<FaIcon :name="item.pinned ? 'thumbtack' : 'thumbtack'" :color="item.pinned ? '#3c9cff' : '#909399'" :size="14" />
<text>{{ item.pinned ? '取消置顶' : '置顶' }}</text>
</view>
<view class="action-btn danger" @tap="onDelete(item)">
<FaIcon name="trash-can" color="#f56c6c" :size="14" />
<text>删除</text>
</view>
</view>
</view>
</view>
<view class="bottom-space" />
</scroll-view>
<view class="fab" @tap="createNote">
<FaIcon name="plus" color="#ffffff" :size="22" />
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { fetchNotes, deleteNote, toggleNotePin } from '@/api/note.js'
import { formatRelativeTime } from '@/utils/date.js'
import { isLoggedIn } from '@/utils/auth.js'
const keyword = ref('')
const notes = ref([])
const total = ref(0)
const loading = ref(false)
const pinnedCount = computed(() => notes.value.filter(item => item.pinned).length)
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
loadNotes()
})
onShow(() => {
if (isLoggedIn()) loadNotes()
})
async function loadNotes() {
loading.value = true
try {
const res = await fetchNotes({ keyword: keyword.value })
notes.value = res.list
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function clearSearch() {
keyword.value = ''
loadNotes()
}
function preview(content) {
const text = (content || '').replace(/\s+/g, ' ').trim()
if (!text) return '暂无内容'
return text.length > 60 ? `${text.slice(0, 60)}...` : text
}
function createNote() {
uni.navigateTo({ url: '/pages/tools/notepad/edit' })
}
function openEdit(id) {
uni.navigateTo({ url: `/pages/tools/notepad/edit?id=${id}` })
}
async function onTogglePin(item) {
try {
await toggleNotePin(item.id)
await loadNotes()
uni.showToast({ title: item.pinned ? '已取消置顶' : '已置顶', icon: 'none' })
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
function onDelete(item) {
uni.showModal({
title: '删除笔记',
content: `确定删除「${item.title}」吗?`,
success: async (res) => {
if (!res.confirm) return
try {
await deleteNote(item.id)
await loadNotes()
uni.showToast({ title: '已删除', 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;
}
.toolbar {
flex-shrink: 0;
padding: 20rpx $page-padding-x 0;
background: $color-card;
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;
padding: 16rpx 4rpx 20rpx;
}
.stats-text {
font-size: 24rpx;
color: $color-text-muted;
}
.list-scroll {
flex: 1;
height: 0;
}
.note-list {
padding: 24rpx $page-padding-x 0;
}
.note-card {
@include card;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
&:active {
opacity: 0.92;
}
}
.note-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 12rpx;
}
.note-title-row {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10rpx;
}
.pin-tag {
flex-shrink: 0;
font-size: 20rpx;
color: $color-primary;
background: $color-primary-bg;
padding: 2rpx 10rpx;
border-radius: $radius-sm;
}
.note-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-time {
flex-shrink: 0;
font-size: 22rpx;
color: $color-text-muted;
}
.note-preview {
display: block;
font-size: 26rpx;
color: $color-text-secondary;
line-height: 1.6;
margin-bottom: 20rpx;
}
.note-actions {
display: flex;
gap: 24rpx;
padding-top: 16rpx;
border-top: 1rpx solid $color-divider;
}
.action-btn {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
color: $color-text-muted;
&.danger {
color: #f56c6c;
}
}
.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;
}
}
.bottom-space {
height: 160rpx;
}
</style>
+321
View File
@@ -0,0 +1,321 @@
<template>
<view class="page">
<view v-if="loading" class="state-box">
<text class="state-text">加载中...</text>
</view>
<template v-else-if="item">
<view class="detail-card">
<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>
</view>
<text class="content-text">{{ item.content || '无内容' }}</text>
</view>
<view class="info-row">
<text class="info-label">发生时间</text>
<text class="info-value">{{ formatDate(item.datetime, true) }}</text>
</view>
<view class="info-row channel-row">
<text class="info-label">提醒渠道</text>
<view class="channel-tags">
<text
v-for="channel in item.remindChannels"
:key="channel"
class="channel-tag"
>{{ getRemindChannelLabel(channel) }}</text>
<text v-if="!item.remindChannels?.length" class="info-value muted">未设置</text>
</view>
</view>
<view class="info-row">
<text class="info-label">提前提醒时间</text>
<text class="info-value">{{ remindText }}</text>
</view>
<view class="meta-row">
<text>创建于 {{ formatDate(item.createdAt, true) }}</text>
<text v-if="item.updatedAt !== item.createdAt">更新于 {{ formatDate(item.updatedAt, true) }}</text>
</view>
</view>
</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>
<view v-if="!item.completed" class="btn-edit" @tap="openEdit">
<FaIcon name="pen-to-square" color="#3c9cff" :size="16" />
<text>编辑</text>
</view>
<view class="btn-delete" @tap="onDelete">
<FaIcon name="trash-can" color="#f56c6c" :size="16" />
<text>删除</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import {
getSchedule,
deleteSchedule,
toggleScheduleComplete,
getRemindChannelLabel,
getRemindMinutesLabel
} from '@/api/schedule.js'
import { formatDate } from '@/utils/date.js'
const scheduleId = ref('')
const item = ref(null)
const loading = ref(false)
const remindText = computed(() => {
if (!item.value) return ''
return getRemindMinutesLabel(item.value.remindMinutes)
})
onLoad((query) => {
if (query?.id) {
scheduleId.value = query.id
loadDetail()
}
})
onShow(() => {
if (scheduleId.value) loadDetail()
})
async function loadDetail() {
loading.value = true
try {
const data = await getSchedule(scheduleId.value)
if (!data) {
uni.showToast({ title: '日程不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
return
}
item.value = data
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function openEdit() {
uni.navigateTo({ url: `/pages/tools/schedule/edit?id=${scheduleId.value}` })
}
async function onToggleComplete() {
if (!item.value) return
try {
const updated = await toggleScheduleComplete(item.value.id)
item.value = updated
uni.showToast({
title: updated.completed ? '已标记完成' : '已标为待办',
icon: 'none'
})
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
function onDelete() {
const summary = (item.value?.content || '').split('\n')[0] || '该日程'
uni.showModal({
title: '删除日程',
content: `确定删除「${summary}」吗?`,
success: async (res) => {
if (!res.confirm) return
try {
await deleteSchedule(scheduleId.value)
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} 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;
padding: 24rpx $page-padding-x;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
overflow-x: hidden;
}
.detail-card {
@include card;
padding: 32rpx 28rpx;
}
.content-block {
padding-bottom: 24rpx;
margin-bottom: 8rpx;
.info-label {
display: block;
margin-bottom: 12rpx;
}
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
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;
color: $color-text;
line-height: 1.7;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
}
.info-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
padding: 20rpx 0;
border-top: 1rpx solid $color-divider;
}
.channel-row {
align-items: flex-start;
}
.info-label {
font-size: 26rpx;
color: $color-text-muted;
flex-shrink: 0;
}
.info-value {
font-size: 28rpx;
color: $color-text;
text-align: right;
&.muted {
color: $color-text-muted;
}
}
.channel-tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10rpx;
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;
gap: 8rpx;
padding-top: 24rpx;
margin-top: 8rpx;
border-top: 1rpx solid $color-divider;
font-size: 24rpx;
color: $color-text-muted;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 20rpx $page-padding-x;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: $color-card;
border-top: 1rpx solid $color-border;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.btn-complete,
.btn-edit,
.btn-delete {
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 {
display: flex;
justify-content: center;
padding: 160rpx 0;
}
.state-text {
font-size: 28rpx;
color: $color-text-muted;
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<template>
<view class="page">
<view v-if="isCompleted" class="completed-notice">
<FaIcon name="circle-check" color="#67c23a" :size="40" />
<text>该日程已完成不可编辑</text>
</view>
<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"
v-model="form.content"
placeholder="输入日程内容,支持多行"
placeholder-class="placeholder"
maxlength="1000"
:show-confirm-bar="false"
:disabled="isCompleted"
/>
<text class="char-count">{{ form.content.length }}/1000</text>
</view>
<view class="field">
<text class="field-label">发生日期 <text class="required">*</text></text>
<picker mode="date" :value="form.date" @change="onDateChange" :disabled="isCompleted">
<view class="picker-value">
<text>{{ form.date || '选择日期' }}</text>
<FaIcon name="calendar-days" color="#909399" :size="16" />
</view>
</picker>
</view>
<view class="field">
<text class="field-label">发生时间 <text class="required">*</text></text>
<picker mode="time" :value="form.time" @change="onTimeChange" :disabled="isCompleted">
<view class="picker-value">
<text>{{ form.time || '选择时间' }}</text>
<FaIcon name="clock" color="#909399" :size="16" />
</view>
</picker>
</view>
<view class="field">
<text class="field-label">提醒渠道</text>
<text class="field-hint">可多选接入接口后按渠道下发提醒</text>
<view class="channel-grid">
<view
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>
</view>
</view>
<view class="field picker-field">
<text class="field-label">提前提醒时间</text>
<picker :range="remindLabels" :value="remindIndex" @change="onRemindChange" :disabled="isCompleted">
<view class="picker-value">
<text>{{ remindLabels[remindIndex] }}</text>
<FaIcon name="bell" color="#909399" :size="16" />
</view>
</picker>
</view>
</view>
<view v-if="!isCompleted" class="footer">
<view class="btn-primary full" :class="{ disabled: saving }" @tap="onSave">
{{ saving ? '保存中...' : '保存' }}
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
getSchedule,
createSchedule,
updateSchedule,
REMIND_CHANNEL_OPTIONS,
REMIND_MINUTES_OPTIONS
} from '@/api/schedule.js'
import { formatDate, pad } from '@/utils/date.js'
const scheduleId = ref('')
const saving = ref(false)
const isCompleted = ref(false)
const channelOptions = REMIND_CHANNEL_OPTIONS
const remindOptions = REMIND_MINUTES_OPTIONS
const remindLabels = remindOptions.map(item => item.label)
const form = reactive({
content: '',
date: '',
time: '',
remindChannels: ['app'],
remindMinutes: 15
})
const remindIndex = computed(() => {
const index = remindOptions.findIndex(item => item.value === form.remindMinutes)
return index >= 0 ? index : 3
})
onLoad(async (query) => {
const now = new Date()
form.date = formatDate(now)
form.time = `${pad(now.getHours())}:${pad(now.getMinutes())}`
if (query?.id) {
scheduleId.value = query.id
uni.setNavigationBarTitle({ title: '编辑日程' })
await loadSchedule(query.id)
} else {
uni.setNavigationBarTitle({ title: '新建日程' })
}
})
async function loadSchedule(id) {
try {
const item = await getSchedule(id)
if (!item) {
uni.showToast({ title: '日程不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
return
}
const d = new Date(item.datetime)
form.content = item.content || ''
form.date = formatDate(d)
form.time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
form.remindChannels = [...(item.remindChannels || [])]
form.remindMinutes = item.remindMinutes ?? 15
isCompleted.value = !!item.completed
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
function toggleChannel(value) {
const index = form.remindChannels.indexOf(value)
if (index >= 0) {
form.remindChannels.splice(index, 1)
} else {
form.remindChannels.push(value)
}
}
function onDateChange(e) {
form.date = e.detail.value
}
function onTimeChange(e) {
form.time = e.detail.value
}
function onRemindChange(e) {
const index = Number(e.detail.value)
form.remindMinutes = remindOptions[index].value
}
function buildDatetime() {
const [year, month, day] = form.date.split('-').map(Number)
const [hour, minute] = form.time.split(':').map(Number)
return new Date(year, month - 1, day, hour, minute).getTime()
}
async function onSave() {
if (saving.value) return
if (!form.content.trim()) {
uni.showToast({ title: '请输入日程内容', icon: 'none' })
return
}
if (!form.date || !form.time) {
uni.showToast({ title: '请选择发生时间', icon: 'none' })
return
}
const datetime = buildDatetime()
if (datetime < Date.now() - 60000 && !scheduleId.value) {
uni.showModal({
title: '时间提示',
content: '发生时间已过去,仍要保存吗?',
success: async (res) => {
if (res.confirm) await saveData(datetime)
}
})
return
}
await saveData(datetime)
}
async function saveData(datetime) {
saving.value = true
const payload = {
content: form.content,
datetime,
remindChannels: [...form.remindChannels],
remindMinutes: form.remindMinutes
}
try {
if (scheduleId.value) {
await updateSchedule(scheduleId.value, payload)
} else {
await createSchedule(payload)
}
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
padding: 24rpx $page-padding-x;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
.completed-notice {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 20rpx 0;
margin-bottom: 16rpx;
font-size: 28rpx;
color: #67c23a;
background: #f0f9eb;
border-radius: $radius-md;
}
.is-disabled {
opacity: 0.7;
pointer-events: none;
}
.form-card {
@include card;
padding: 8rpx 0;
}
.field {
padding: 24rpx 28rpx;
& + & {
border-top: 1rpx solid $color-divider;
}
}
.field-label {
display: block;
font-size: 24rpx;
color: $color-text-muted;
margin-bottom: 12rpx;
}
.field-hint {
display: block;
font-size: 22rpx;
color: $color-text-muted;
margin-bottom: 16rpx;
}
.required {
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;
justify-content: space-between;
height: 72rpx;
padding: 0 20rpx;
background: $color-bg-page;
border-radius: $radius-md;
font-size: 28rpx;
color: $color-text;
}
.channel-grid {
display: flex;
flex-wrap: wrap;
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;
}
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 20rpx;
padding: 20rpx $page-padding-x;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: $color-card;
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>
+428
View File
@@ -0,0 +1,428 @@
<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"
/>
<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>
<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
v-for="item in group.items"
:key="item.id"
class="schedule-card"
:class="{ done: item.completed }"
>
<view class="schedule-main" @tap="openDetail(item.id)">
<view class="schedule-head">
<view class="status-dot" :class="item.completed ? 'done' : 'pending'" />
<text class="schedule-title">{{ getScheduleSummary(item) }}</text>
</view>
<view class="schedule-meta">
<FaIcon name="clock" color="#909399" :size="12" />
<text>{{ formatDate(item.datetime, true) }}</text>
</view>
<view v-if="item.remindChannels?.length" class="channel-row">
<text
v-for="ch in item.remindChannels"
:key="ch"
class="channel-mini"
>{{ getRemindChannelLabel(ch) }}</text>
<text v-if="item.remindMinutes" class="remind-text">{{ getRemindMinutesLabel(item.remindMinutes) }}</text>
</view>
</view>
<view class="action-row">
<view
class="action-btn"
:class="{ disabled: item.completed }"
@tap="!item.completed && onToggleComplete(item)"
>
<FaIcon name="check" :color="item.completed ? '#c0c4cc' : '#3c9cff'" :size="14" />
<text>{{ item.completed ? '已完成' : '标记完成' }}</text>
</view>
<view v-if="!item.completed" class="action-btn" @tap="openEdit(item.id)">
<FaIcon name="pen-to-square" color="#3c9cff" :size="14" />
<text>编辑</text>
</view>
</view>
</view>
</view>
</view>
<view class="bottom-space" />
</scroll-view>
<view class="fab" @tap="createSchedule">
<FaIcon name="plus" color="#ffffff" :size="22" />
</view>
</view>
</template>
<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 { isLoggedIn } from '@/utils/auth.js'
const keyword = ref('')
const status = ref('all')
const schedules = ref([])
const loading = ref(false)
const tabs = [
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待办' },
{ value: 'done', label: '已完成' }
]
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)
})
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
loadSchedules()
})
onShow(() => {
if (isLoggedIn()) loadSchedules()
})
async function loadSchedules() {
loading.value = true
try {
const res = await fetchSchedules({
keyword: keyword.value,
status: status.value
})
schedules.value = res.list
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function clearSearch() {
keyword.value = ''
loadSchedules()
}
function changeStatus(value) {
status.value = value
loadSchedules()
}
function createSchedule() {
uni.navigateTo({ url: '/pages/tools/schedule/edit' })
}
function openDetail(id) {
uni.navigateTo({ url: `/pages/tools/schedule/detail?id=${id}` })
}
function openEdit(id) {
uni.navigateTo({ url: `/pages/tools/schedule/edit?id=${id}` })
}
async function onToggleComplete(item) {
try {
await toggleScheduleComplete(item.id)
await loadSchedules()
uni.showToast({
title: item.completed ? '已标为待办' : '已标记完成',
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;
}
.toolbar {
flex-shrink: 0;
padding: 20rpx $page-padding-x 0;
background: $color-card;
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;
}
.tabs {
display: flex;
gap: 16rpx;
padding: 20rpx 0 24rpx;
}
.tab {
padding: 10rpx 28rpx;
border-radius: $radius-full;
font-size: 26rpx;
color: $color-text-secondary;
background: $color-bg-page;
&.active {
color: $color-primary;
background: $color-primary-bg;
font-weight: 500;
}
}
.list-scroll {
flex: 1;
height: 0;
}
.schedule-list {
padding: 24rpx $page-padding-x 0;
}
.group {
margin-bottom: 28rpx;
}
.group-title {
display: block;
font-size: 26rpx;
font-weight: 600;
color: $color-text-secondary;
margin-bottom: 16rpx;
padding-left: 4rpx;
}
.schedule-card {
@include card;
margin-bottom: 16rpx;
overflow: hidden;
&.done .schedule-title {
text-decoration: line-through;
color: $color-text-muted;
}
}
.schedule-main {
padding: 24rpx 24rpx 16rpx;
&: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%;
flex-shrink: 0;
&.pending {
background: $color-primary;
}
&.done {
background: #67c23a;
}
}
.schedule-title {
flex: 1;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-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;
color: $color-text-muted;
}
.action-row {
display: flex;
border-top: 1rpx solid $color-divider;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 20rpx 0;
font-size: 24rpx;
color: $color-text-muted;
& + & {
border-left: 1rpx solid $color-divider;
}
&.danger {
color: #f56c6c;
}
&.disabled {
color: #c0c4cc;
}
&: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;
}
}
.bottom-space {
height: 160rpx;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
@import '@/src/styles/theme.scss';
@mixin page-shell {
height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
overflow: hidden;
}
@mixin scroll-body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 24rpx $page-padding-x 0;
}
@mixin card {
background: $color-card;
border-radius: $radius-lg;
box-shadow: $shadow-card;
}
@mixin section-title {
font-size: 32rpx;
font-weight: 600;
color: $color-text;
}
@mixin icon-box($size: 72rpx) {
width: $size;
height: $size;
border-radius: $radius-md;
background: $color-primary-bg;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
+16 -17
View File
@@ -1,19 +1,18 @@
// 简约潮流主题变量
$color-primary: #6366f1;
$color-primary-light: #818cf8;
$color-primary-dark: #4f46e5;
$color-accent: #ec4899;
$color-bg: #f8fafc;
// 标准商务主题(单色蓝 + 灰阶,无渐变)
$color-primary: #3c9cff;
$color-primary-dark: #2b8ae8;
$color-primary-bg: #ecf5ff;
$color-bg-page: #f5f7fa;
$color-card: #ffffff;
$color-text: #1e293b;
$color-text-secondary: #64748b;
$color-text-muted: #94a3b8;
$color-border: #e2e8f0;
$gradient-primary: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
$gradient-soft: linear-gradient(160deg, #eef2ff 0%, #faf5ff 50%, #fdf2f8 100%);
$shadow-sm: 0 2rpx 12rpx rgba(99, 102, 241, 0.08);
$shadow-md: 0 8rpx 32rpx rgba(99, 102, 241, 0.12);
$radius-sm: 16rpx;
$radius-md: 24rpx;
$radius-lg: 32rpx;
$color-text: #303133;
$color-text-secondary: #606266;
$color-text-muted: #909399;
$color-border: #ebeef5;
$color-divider: #f2f3f5;
$shadow-card: 0 4rpx 24rpx rgba(0, 0, 0, 0.05);
$shadow-header: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
$radius-sm: 8rpx;
$radius-md: 12rpx;
$radius-lg: 16rpx;
$radius-full: 999rpx;
$page-padding-x: 32rpx;
+73
View File
@@ -0,0 +1,73 @@
export function pad(n) {
return String(n).padStart(2, '0')
}
export function toDate(value) {
if (!value) return null
const d = value instanceof Date ? value : new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
export function formatDate(value, withTime = false) {
const d = toDate(value)
if (!d) return ''
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
if (!withTime) return date
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function formatRelativeTime(value) {
const d = toDate(value)
if (!d) return ''
const now = new Date()
const diff = now.getTime() - d.getTime()
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return '刚刚'
if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`
if (diff < day) return `${Math.floor(diff / hour)} 小时前`
if (diff < 7 * day) return `${Math.floor(diff / day)} 天前`
return formatDate(d)
}
export function isSameDay(a, b) {
const da = toDate(a)
const db = toDate(b)
if (!da || !db) return false
return (
da.getFullYear() === db.getFullYear() &&
da.getMonth() === db.getMonth() &&
da.getDate() === db.getDate()
)
}
export function isToday(value) {
return isSameDay(value, new Date())
}
export function isTomorrow(value) {
const d = toDate(value)
if (!d) return false
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
return isSameDay(d, tomorrow)
}
export function dayLabel(value) {
if (isToday(value)) return '今天'
if (isTomorrow(value)) return '明天'
return formatDate(value)
}
export function startOfDay(value = new Date()) {
const d = toDate(value) || new Date()
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
export function endOfDay(value = new Date()) {
const d = startOfDay(value)
d.setHours(23, 59, 59, 999)
return d
}