This commit is contained in:
2025-06-25 12:27:52 +08:00
commit 960c4b7da4
190 changed files with 38050 additions and 0 deletions

View File

@ -0,0 +1,5 @@
<template></template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,5 @@
<template></template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,532 @@
<template>
<view class="constitution-identification">
<!-- 当前体质状态 -->
<view v-if="userConstitution" class="current-constitution">
<view class="constitution-card">
<view class="constitution-header">
<text class="constitution-name">{{ userConstitution.name }}</text>
<text class="constitution-date">{{ formatDate(userConstitution.testDate) }}</text>
</view>
<view class="constitution-description">
<text>{{ userConstitution.description }}</text>
</view>
<view class="constitution-characteristics">
<text class="characteristics-title">主要特征</text>
<view class="characteristics-list">
<text
v-for="item in userConstitution.characteristics"
:key="item"
class="characteristic-item"
>
{{ item }}
</text>
</view>
</view>
<view class="constitution-score">
<text class="score-label">体质得分</text>
<text class="score-value">{{ userConstitution.score }}</text>
</view>
</view>
</view>
<!-- 体质调理建议 -->
<view class="constitution-advice">
<view class="section-title">调理建议</view>
<view class="advice-tabs">
<view
v-for="tab in adviceTabs"
:key="tab.key"
class="advice-tab"
:class="{ active: activeAdviceTab === tab.key }"
@click="switchAdviceTab(tab.key)"
>
<text>{{ tab.label }}</text>
</view>
</view>
<view class="advice-content">
<view v-for="advice in currentAdvices" :key="advice.id" class="advice-item">
<view class="advice-header">
<text class="advice-title">{{ advice.title }}</text>
<text class="advice-level" :class="advice.level">{{ advice.levelText }}</text>
</view>
<text class="advice-description">{{ advice.description }}</text>
</view>
</view>
</view>
<!-- 九种体质介绍 -->
<view class="constitution-types">
<view class="section-title">九种体质介绍</view>
<view class="constitution-grid">
<view
v-for="type in constitutionTypes"
:key="type.id"
class="constitution-type-card"
@click="viewConstitutionDetail(type)"
>
<view class="type-icon" :style="{ backgroundColor: type.color }">
<text>{{ type.name.charAt(0) }}</text>
</view>
<text class="type-name">{{ type.name }}</text>
<text class="type-description">{{ type.shortDesc }}</text>
</view>
</view>
</view>
<!-- 底部操作 -->
<view class="bottom-actions">
<button class="action-btn primary" @click="startTest">重新测试</button>
<button class="action-btn secondary" @click="viewHistory">测试历史</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
interface Constitution {
name: string;
description: string;
characteristics: string[];
score: number;
testDate: Date;
}
// interface AdviceItem {
// id: string;
// title: string;
// description: string;
// level: "high" | "medium" | "low";
// levelText: string;
// type: "diet" | "exercise" | "lifestyle" | "emotion";
// }
interface ConstitutionType {
id: string;
name: string;
shortDesc: string;
color: string;
description: string;
}
const userConstitution = ref<Constitution>({
name: "气虚体质",
description:
"气虚体质是指脏腑功能衰弱,气的推动、温煦、防御、固摄和气化功能减退,从而导致某些功能活动低下或衰退,抗病能力下降等衰弱的现象。",
characteristics: ["容易疲劳", "气短懒言", "易出汗", "舌淡苔白", "脉虚弱"],
score: 75,
testDate: new Date(),
});
const adviceTabs = [
{ key: "diet", label: "饮食调理" },
{ key: "exercise", label: "运动调理" },
{ key: "lifestyle", label: "生活调理" },
{ key: "emotion", label: "情志调理" },
];
const activeAdviceTab = ref("diet");
const adviceData = {
diet: [
{
id: "1",
title: "补气食材",
description: "多食用山药、大枣、蜂蜜、鸡肉等具有补气作用的食物",
level: "high",
levelText: "重要",
type: "diet",
},
{
id: "2",
title: "忌食寒凉",
description: "避免生冷寒凉食物,如冰饮、西瓜等",
level: "medium",
levelText: "注意",
type: "diet",
},
],
exercise: [
{
id: "3",
title: "温和运动",
description: "适宜散步、太极拳、八段锦等柔和的运动",
level: "high",
levelText: "推荐",
type: "exercise",
},
{
id: "4",
title: "避免剧烈运动",
description: "不宜进行大强度、长时间的运动",
level: "medium",
levelText: "注意",
type: "exercise",
},
],
lifestyle: [
{
id: "5",
title: "规律作息",
description: "保证充足睡眠,避免熬夜,建立规律的作息时间",
level: "high",
levelText: "重要",
type: "lifestyle",
},
],
emotion: [
{
id: "6",
title: "保持乐观",
description: "保持积极乐观的心态,避免过度忧虑",
level: "medium",
levelText: "建议",
type: "emotion",
},
],
};
const constitutionTypes = ref<ConstitutionType[]>([
{
id: "1",
name: "平和质",
shortDesc: "体质均衡",
color: "#4CAF50",
description: "身体健康,精力充沛",
},
{
id: "2",
name: "气虚质",
shortDesc: "容易疲劳",
color: "#FFC107",
description: "脏腑功能衰弱,气不足",
},
{
id: "3",
name: "阳虚质",
shortDesc: "畏寒怕冷",
color: "#FF9800",
description: "阳气不足,机体功能衰退",
},
{
id: "4",
name: "阴虚质",
shortDesc: "口干舌燥",
color: "#F44336",
description: "阴液不足,机体失润",
},
{
id: "5",
name: "痰湿质",
shortDesc: "形体肥胖",
color: "#9C27B0",
description: "痰湿凝聚,代谢异常",
},
{
id: "6",
name: "湿热质",
shortDesc: "面垢油腻",
color: "#E91E63",
description: "湿热内蕴,代谢异常",
},
{
id: "7",
name: "血瘀质",
shortDesc: "肤色晦暗",
color: "#3F51B5",
description: "血行不畅,瘀血内阻",
},
{
id: "8",
name: "气郁质",
shortDesc: "情绪低落",
color: "#009688",
description: "气机郁滞,情志异常",
},
{ id: "9", name: "特禀质", shortDesc: "先天异常", color: "#795548", description: "先天禀赋异常" },
]);
const currentAdvices = computed(() => {
return adviceData[activeAdviceTab.value as keyof typeof adviceData] || [];
});
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}${month}${day}`;
};
const switchAdviceTab = (tab: string) => {
activeAdviceTab.value = tab;
};
const startTest = () => {
uni.navigateTo({
url: "/pages/health/constitution/test",
});
};
const viewHistory = () => {
uni.navigateTo({
url: "/pages/health/constitution/history",
});
};
const viewConstitutionDetail = (type: ConstitutionType) => {
uni.navigateTo({
url: `/pages/health/constitution/detail?id=${type.id}`,
});
};
</script>
<style lang="scss" scoped>
.constitution-identification {
min-height: 100vh;
padding: 20rpx;
background-color: #f8f9fa;
}
.current-constitution {
margin-bottom: 20rpx;
}
.constitution-card {
padding: 30rpx;
color: white;
background: linear-gradient(135deg, #4caf50, #45a049);
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(76, 175, 80, 0.3);
}
.constitution-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.constitution-name {
font-size: 32rpx;
font-weight: 600;
}
.constitution-date {
font-size: 22rpx;
opacity: 0.8;
}
}
.constitution-description {
margin-bottom: 20rpx;
font-size: 24rpx;
line-height: 1.6;
opacity: 0.9;
}
.constitution-characteristics {
margin-bottom: 20rpx;
.characteristics-title {
margin-bottom: 12rpx;
font-size: 24rpx;
font-weight: 600;
}
.characteristics-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
.characteristic-item {
padding: 8rpx 16rpx;
font-size: 22rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 20rpx;
}
}
}
.constitution-score {
display: flex;
align-items: center;
.score-label {
margin-right: 12rpx;
font-size: 24rpx;
}
.score-value {
font-size: 32rpx;
font-weight: 600;
}
}
.constitution-advice {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.section-title {
margin-bottom: 24rpx;
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.advice-tabs {
display: flex;
padding: 8rpx;
margin-bottom: 24rpx;
background: #f8f9fa;
border-radius: 12rpx;
}
.advice-tab {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 60rpx;
font-size: 24rpx;
color: #666;
border-radius: 8rpx;
transition: all 0.3s;
&.active {
color: white;
background: #4caf50;
}
}
.advice-content {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.advice-item {
padding: 20rpx;
background: #f8f9fa;
border-radius: 12rpx;
.advice-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
.advice-title {
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.advice-level {
padding: 6rpx 12rpx;
font-size: 20rpx;
border-radius: 16rpx;
&.high {
color: #f44336;
background: #ffebee;
}
&.medium {
color: #ff9800;
background: #fff3e0;
}
&.low {
color: #4caf50;
background: #e8f5e8;
}
}
}
.advice-description {
font-size: 24rpx;
line-height: 1.5;
color: #666;
}
}
.constitution-types {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.constitution-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.constitution-type-card {
padding: 20rpx;
text-align: center;
background: #f8f9fa;
border-radius: 12rpx;
transition: all 0.3s;
&:active {
transform: scale(0.95);
}
.type-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
margin: 0 auto 12rpx;
font-size: 28rpx;
font-weight: 600;
color: white;
border-radius: 50%;
}
.type-name {
display: block;
margin-bottom: 8rpx;
font-size: 24rpx;
font-weight: 600;
color: #333;
}
.type-description {
font-size: 20rpx;
color: #666;
}
}
.bottom-actions {
display: flex;
gap: 20rpx;
padding: 30rpx 0;
.action-btn {
flex: 1;
height: 88rpx;
font-size: 28rpx;
font-weight: 600;
border: none;
border-radius: 44rpx;
&.primary {
color: white;
background: #4caf50;
}
&.secondary {
color: #666;
background: #f0f0f0;
}
}
}
</style>

View File

@ -0,0 +1,537 @@
<template>
<view class="constitution-test">
<!-- 测试进度 -->
<view class="test-progress">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progressWidth }"></view>
</view>
<text class="progress-text">{{ currentQuestionIndex + 1 }}/{{ questions.length }}</text>
</view>
<!-- 当前问题 -->
<view v-if="currentQuestion" class="question-container">
<view class="question-number">{{ currentQuestionIndex + 1 }}</view>
<view class="question-text">{{ currentQuestion.question }}</view>
<!-- 选项列表 -->
<view class="options-list">
<view
v-for="(option, index) in currentQuestion.options"
:key="index"
class="option-item"
:class="{ selected: selectedOption === index }"
@click="selectOption(index)"
>
<view class="option-radio">
<view v-if="selectedOption === index" class="radio-dot"></view>
</view>
<text class="option-text">{{ option.text }}</text>
</view>
</view>
</view>
<!-- 测试结果 -->
<view v-if="testCompleted" class="test-result">
<view class="result-header">
<text class="result-title">测试完成</text>
<text class="result-subtitle">您的体质类型是</text>
</view>
<view class="result-constitution">
<view class="constitution-icon" :style="{ backgroundColor: result.color }">
<text>{{ result.name.charAt(0) }}</text>
</view>
<text class="constitution-name">{{ result.name }}</text>
<text class="constitution-score">得分{{ result.score }}</text>
</view>
<view class="result-description">
<text>{{ result.description }}</text>
</view>
<view class="result-characteristics">
<text class="characteristics-title">主要特征</text>
<view class="characteristics-tags">
<text v-for="char in result.characteristics" :key="char" class="characteristic-tag">
{{ char }}
</text>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-actions">
<button
v-if="!testCompleted && currentQuestionIndex > 0"
class="action-btn secondary"
@click="previousQuestion"
>
上一题
</button>
<button
v-if="!testCompleted && selectedOption !== null"
class="action-btn primary"
@click="nextQuestion"
>
{{ isLastQuestion ? "完成测试" : "下一题" }}
</button>
<button v-if="testCompleted" class="action-btn primary full-width" @click="saveResult">
保存结果
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
interface QuestionOption {
text: string;
score: number;
constitution: string;
}
interface Question {
id: string;
question: string;
options: QuestionOption[];
}
interface TestResult {
name: string;
score: number;
description: string;
characteristics: string[];
color: string;
}
const currentQuestionIndex = ref(0);
const selectedOption = ref<number | null>(null);
const answers = ref<number[]>([]);
const testCompleted = ref(false);
const questions = ref<Question[]>([
{
id: "1",
question: "您平时是否容易疲劳?",
options: [
{ text: "非常容易疲劳,经常感到乏力", score: 5, constitution: "气虚" },
{ text: "比较容易疲劳", score: 3, constitution: "气虚" },
{ text: "偶尔疲劳", score: 2, constitution: "平和" },
{ text: "很少疲劳,精力充沛", score: 1, constitution: "平和" },
],
},
{
id: "2",
question: "您的睡眠质量如何?",
options: [
{ text: "经常失眠,睡眠质量很差", score: 5, constitution: "阴虚" },
{ text: "睡眠较浅,容易醒", score: 4, constitution: "阴虚" },
{ text: "睡眠一般", score: 2, constitution: "平和" },
{ text: "睡眠很好,很少失眠", score: 1, constitution: "平和" },
],
},
{
id: "3",
question: "您对寒冷的感受如何?",
options: [
{ text: "非常怕冷,手脚冰凉", score: 5, constitution: "阳虚" },
{ text: "比较怕冷", score: 3, constitution: "阳虚" },
{ text: "对温度变化不太敏感", score: 2, constitution: "平和" },
{ text: "不怕冷,甚至喜欢凉爽", score: 1, constitution: "湿热" },
],
},
{
id: "4",
question: "您的情绪状态通常如何?",
options: [
{ text: "经常感到抑郁、烦躁", score: 5, constitution: "气郁" },
{ text: "情绪起伏较大", score: 3, constitution: "气郁" },
{ text: "情绪比较稳定", score: 2, constitution: "平和" },
{ text: "心情愉快,很少烦躁", score: 1, constitution: "平和" },
],
},
{
id: "5",
question: "您的消化功能如何?",
options: [
{ text: "消化不良,经常腹胀", score: 5, constitution: "痰湿" },
{ text: "消化一般,偶有不适", score: 3, constitution: "痰湿" },
{ text: "消化功能正常", score: 2, constitution: "平和" },
{ text: "消化很好,食欲旺盛", score: 1, constitution: "平和" },
],
},
]);
const constitutionResults = {
气虚: {
name: "气虚体质",
description: "脏腑功能衰弱,气的推动、温煦、防御、固摄和气化功能减退",
characteristics: ["容易疲劳", "气短懒言", "易出汗", "舌淡苔白"],
color: "#FFC107",
},
阳虚: {
name: "阳虚体质",
description: "阳气不足,机体功能衰退,物质代谢活动减退",
characteristics: ["畏寒怕冷", "手脚冰凉", "精神不振", "舌淡胖"],
color: "#FF9800",
},
阴虚: {
name: "阴虚体质",
description: "阴液不足,机体失润,以脏腑失养为主要特征",
characteristics: ["口干舌燥", "五心烦热", "盗汗", "舌红少苔"],
color: "#F44336",
},
痰湿: {
name: "痰湿体质",
description: "痰湿凝聚,代谢异常,以形体肥胖为主要特征",
characteristics: ["形体肥胖", "腹部肥满", "口黏腻", "舌苔厚腻"],
color: "#9C27B0",
},
湿热: {
name: "湿热体质",
description: "湿热内蕴,以面垢油腻、口苦、苔黄腻为主要特征",
characteristics: ["面部油腻", "口苦口干", "身重困倦", "舌苔黄腻"],
color: "#E91E63",
},
气郁: {
name: "气郁体质",
description: "气机郁滞,神情抑郁,忧虑脆弱",
characteristics: ["情绪低落", "胸胁胀满", "多愁善感", "舌淡红"],
color: "#009688",
},
平和: {
name: "平和体质",
description: "阴阳气血调和,体质平和,身体健康",
characteristics: ["精力充沛", "睡眠良好", "食欲正常", "舌色淡红"],
color: "#4CAF50",
},
};
const result = ref<TestResult>({
name: "",
score: 0,
description: "",
characteristics: [],
color: "",
});
const currentQuestion = computed(() => {
return questions.value[currentQuestionIndex.value];
});
const progressWidth = computed(() => {
if (testCompleted.value) return "100%";
return `${((currentQuestionIndex.value + 1) / questions.value.length) * 100}%`;
});
const isLastQuestion = computed(() => {
return currentQuestionIndex.value === questions.value.length - 1;
});
const selectOption = (index: number) => {
selectedOption.value = index;
};
const nextQuestion = () => {
if (selectedOption.value === null) return;
answers.value[currentQuestionIndex.value] = selectedOption.value;
if (isLastQuestion.value) {
calculateResult();
testCompleted.value = true;
} else {
currentQuestionIndex.value++;
selectedOption.value = answers.value[currentQuestionIndex.value] ?? null;
}
};
const previousQuestion = () => {
if (currentQuestionIndex.value > 0) {
currentQuestionIndex.value--;
selectedOption.value = answers.value[currentQuestionIndex.value] ?? null;
}
};
const calculateResult = () => {
const constitutionScores: { [key: string]: number } = {};
answers.value.forEach((answerIndex, questionIndex) => {
const question = questions.value[questionIndex];
const selectedAnswer = question.options[answerIndex];
const constitution = selectedAnswer.constitution;
constitutionScores[constitution] =
(constitutionScores[constitution] || 0) + selectedAnswer.score;
});
// 找出得分最高的体质
let maxScore = 0;
let dominantConstitution = "平和";
Object.entries(constitutionScores).forEach(([constitution, score]) => {
if (score > maxScore) {
maxScore = score;
dominantConstitution = constitution;
}
});
const constitutionData =
constitutionResults[dominantConstitution as keyof typeof constitutionResults];
result.value = {
name: constitutionData.name,
score: Math.min(Math.round((maxScore / (questions.value.length * 5)) * 100), 100),
description: constitutionData.description,
characteristics: constitutionData.characteristics,
color: constitutionData.color,
};
};
const saveResult = () => {
// 保存测试结果
uni.showToast({
title: "结果已保存",
icon: "success",
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
};
</script>
<style lang="scss" scoped>
.constitution-test {
min-height: 100vh;
padding: 20rpx;
background-color: #f8f9fa;
}
.test-progress {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
.progress-bar {
height: 8rpx;
margin-bottom: 16rpx;
overflow: hidden;
background: #f0f0f0;
border-radius: 4rpx;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4caf50, #45a049);
border-radius: 4rpx;
transition: width 0.3s ease;
}
}
.progress-text {
display: block;
font-size: 24rpx;
color: #666;
text-align: center;
}
}
.question-container {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.question-number {
margin-bottom: 16rpx;
font-size: 24rpx;
font-weight: 600;
color: #4caf50;
}
.question-text {
margin-bottom: 30rpx;
font-size: 28rpx;
font-weight: 500;
line-height: 1.6;
color: #333;
}
.options-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.option-item {
display: flex;
align-items: center;
padding: 20rpx;
background: #f8f9fa;
border: 2rpx solid transparent;
border-radius: 12rpx;
transition: all 0.3s;
&.selected {
background: #e8f5e8;
border-color: #4caf50;
}
.option-radio {
display: flex;
align-items: center;
justify-content: center;
width: 36rpx;
height: 36rpx;
margin-right: 20rpx;
border: 2rpx solid #ddd;
border-radius: 50%;
.radio-dot {
width: 20rpx;
height: 20rpx;
background: #4caf50;
border-radius: 50%;
}
}
.option-text {
flex: 1;
font-size: 26rpx;
line-height: 1.5;
color: #333;
}
&.selected .option-radio {
border-color: #4caf50;
}
}
.test-result {
padding: 40rpx;
margin-bottom: 20rpx;
text-align: center;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.result-header {
margin-bottom: 30rpx;
.result-title {
display: block;
margin-bottom: 12rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.result-subtitle {
font-size: 24rpx;
color: #666;
}
}
.result-constitution {
margin-bottom: 30rpx;
.constitution-icon {
display: flex;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
margin: 0 auto 20rpx;
font-size: 40rpx;
font-weight: 600;
color: white;
border-radius: 50%;
}
.constitution-name {
display: block;
margin-bottom: 12rpx;
font-size: 36rpx;
font-weight: 600;
color: #333;
}
.constitution-score {
font-size: 28rpx;
font-weight: 600;
color: #4caf50;
}
}
.result-description {
margin-bottom: 30rpx;
font-size: 26rpx;
line-height: 1.6;
color: #666;
text-align: left;
}
.result-characteristics {
text-align: left;
.characteristics-title {
display: block;
margin-bottom: 16rpx;
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.characteristics-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
.characteristic-tag {
padding: 8rpx 16rpx;
font-size: 22rpx;
color: #4caf50;
background: #e8f5e8;
border-radius: 20rpx;
}
}
}
.bottom-actions {
display: flex;
gap: 20rpx;
padding: 30rpx 0;
.action-btn {
height: 88rpx;
font-size: 28rpx;
font-weight: 600;
border: none;
border-radius: 44rpx;
&:not(.full-width) {
flex: 1;
}
&.full-width {
width: 100%;
}
&.primary {
color: white;
background: #4caf50;
}
&.secondary {
color: #666;
background: #f0f0f0;
}
}
}
</style>

View File

@ -0,0 +1,5 @@
<template></template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,5 @@
<template></template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,478 @@
<template>
<view class="consultation-page">
<!-- 顶部搜索 -->
<view class="search-section">
<view class="search-bar">
<text class="search-icon">🔍</text>
<input v-model="searchKeyword" placeholder="搜索医生、科室或疾病" @confirm="handleSearch" />
</view>
</view>
<!-- 科室分类 -->
<view class="department-section">
<view class="section-title">科室分类</view>
<view class="department-grid">
<view
v-for="dept in departments"
:key="dept.id"
class="department-item"
@click="selectDepartment(dept)"
>
<view class="dept-icon">
<text class="icon">{{ dept.icon }}</text>
</view>
<text class="dept-name">{{ dept.name }}</text>
</view>
</view>
</view>
<!-- 在线医生 -->
<view class="doctor-section">
<view class="section-header">
<text class="section-title">在线医生</text>
<text class="more-link" @click="viewAllDoctors">查看全部</text>
</view>
<view class="doctor-list">
<view
v-for="doctor in onlineDoctors"
:key="doctor.id"
class="doctor-card"
@click="selectDoctor(doctor)"
>
<image class="doctor-avatar" :src="doctor.avatar" mode="aspectFill" />
<view class="doctor-info">
<view class="doctor-name">{{ doctor.name }}</view>
<view class="doctor-title">{{ doctor.title }}</view>
<view class="doctor-hospital">{{ doctor.hospital }}</view>
<view class="doctor-rating">
<text class="rating-text"> {{ doctor.rating }}</text>
<text class="experience">{{ doctor.experience }}年经验</text>
</view>
</view>
<view class="doctor-price">
<text class="price">¥{{ doctor.consultationFee }}</text>
<view class="online-status">在线</view>
</view>
</view>
</view>
</view>
<!-- 咨询历史 -->
<view class="history-section">
<view class="section-header">
<text class="section-title">咨询历史</text>
<text class="more-link" @click="viewHistory">查看全部</text>
</view>
<view class="history-list">
<view
v-for="consultation in consultationHistory"
:key="consultation.id"
class="history-item"
@click="viewConsultation(consultation)"
>
<image class="doctor-avatar" :src="consultation.doctorAvatar" mode="aspectFill" />
<view class="consultation-info">
<view class="doctor-name">{{ consultation.doctorName }}</view>
<view class="consultation-time">{{ formatTime(consultation.startTime) }}</view>
<view class="consultation-status" :class="consultation.status">
{{ getStatusText(consultation.status) }}
</view>
</view>
<view class="consultation-type">
<text class="type-icon">{{ getTypeIcon(consultation.type) }}</text>
</view>
</view>
</view>
</view>
<!-- 快速咨询入口 -->
<view class="quick-consultation">
<button class="quick-btn" @click="quickConsultation">
<text class="btn-icon"></text>
<text class="btn-text">快速咨询</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
const searchKeyword = ref("");
const departments = ref([
{ id: 1, name: "内科", icon: "🫀" },
{ id: 2, name: "外科", icon: "🔪" },
{ id: 3, name: "妇科", icon: "👩" },
{ id: 4, name: "儿科", icon: "👶" },
{ id: 5, name: "骨科", icon: "🦴" },
{ id: 6, name: "皮肤科", icon: "🧴" },
{ id: 7, name: "眼科", icon: "👁️" },
{ id: 8, name: "口腔科", icon: "🦷" },
]);
const onlineDoctors = ref([
{
id: "1",
name: "张医生",
title: "主任医师",
hospital: "三甲医院",
avatar: "/static/images/doctor1.jpg",
rating: 4.8,
experience: 15,
consultationFee: 50,
},
{
id: "2",
name: "李医生",
title: "副主任医师",
hospital: "二甲医院",
avatar: "/static/images/doctor2.jpg",
rating: 4.6,
experience: 12,
consultationFee: 40,
},
]);
const consultationHistory = ref([
{
id: "1",
doctorName: "王医生",
doctorAvatar: "/static/images/doctor3.jpg",
startTime: new Date().toISOString(),
status: "completed",
type: "text",
},
]);
const handleSearch = () => {
console.log("搜索:", searchKeyword.value);
};
const selectDepartment = (dept: any) => {
uni.navigateTo({ url: `/pages/health/consultation/doctor-list?department=${dept.name}` });
};
const selectDoctor = (doctor: any) => {
uni.navigateTo({ url: `/pages/health/consultation/chat?doctorId=${doctor.id}` });
};
const viewAllDoctors = () => {
uni.navigateTo({ url: "/pages/health/consultation/doctor-list" });
};
const viewHistory = () => {
uni.navigateTo({ url: "/pages/health/consultation/history" });
};
const viewConsultation = (consultation: any) => {
uni.navigateTo({ url: `/pages/health/consultation/chat?consultationId=${consultation.id}` });
};
const quickConsultation = () => {
uni.navigateTo({ url: "/pages/health/consultation/quick" });
};
const formatTime = (time: string) => {
return new Date(time).toLocaleDateString("zh-CN");
};
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
pending: "待开始",
ongoing: "进行中",
completed: "已完成",
cancelled: "已取消",
};
return statusMap[status] || status;
};
const getTypeIcon = (type: string) => {
const typeMap: Record<string, string> = {
text: "💬",
voice: "🎤",
video: "📹",
};
return typeMap[type] || "💬";
};
</script>
<style lang="scss" scoped>
.consultation-page {
min-height: 100vh;
padding: 20rpx;
background: #f5f5f5;
}
.search-section {
margin-bottom: 30rpx;
.search-bar {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background: white;
border-radius: 25rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.search-icon {
margin-right: 20rpx;
font-size: 30rpx;
color: #999;
}
input {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
.department-section {
padding: 30rpx;
margin-bottom: 30rpx;
background: white;
border-radius: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.section-title {
margin-bottom: 30rpx;
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.department-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 30rpx;
}
.department-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
background: #f8f9fa;
border-radius: 15rpx;
transition: background 0.3s;
&:active {
background: #e9ecef;
}
.dept-icon {
margin-bottom: 15rpx;
.icon {
font-size: 40rpx;
}
}
.dept-name {
font-size: 24rpx;
color: #333;
text-align: center;
}
}
}
.doctor-section,
.history-section {
padding: 30rpx;
margin-bottom: 30rpx;
background: white;
border-radius: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.more-link {
font-size: 26rpx;
color: #007aff;
}
}
}
.doctor-list {
.doctor-card {
display: flex;
align-items: center;
padding: 25rpx;
margin-bottom: 20rpx;
background: #f8f9fa;
border-radius: 15rpx;
transition: background 0.3s;
&:active {
background: #e9ecef;
}
.doctor-avatar {
width: 100rpx;
height: 100rpx;
margin-right: 20rpx;
border-radius: 50%;
}
.doctor-info {
flex: 1;
.doctor-name {
margin-bottom: 8rpx;
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.doctor-title {
margin-bottom: 8rpx;
font-size: 24rpx;
color: #666;
}
.doctor-hospital {
margin-bottom: 8rpx;
font-size: 24rpx;
color: #999;
}
.doctor-rating {
display: flex;
align-items: center;
.rating-text {
margin-right: 20rpx;
font-size: 22rpx;
color: #ff9500;
}
.experience {
font-size: 22rpx;
color: #666;
}
}
}
.doctor-price {
text-align: right;
.price {
display: block;
margin-bottom: 10rpx;
font-size: 28rpx;
font-weight: bold;
color: #ff3b30;
}
.online-status {
padding: 5rpx 15rpx;
font-size: 20rpx;
color: #34c759;
background: #e8f5e8;
border-radius: 10rpx;
}
}
}
}
.history-list {
.history-item {
display: flex;
align-items: center;
padding: 25rpx;
margin-bottom: 20rpx;
background: #f8f9fa;
border-radius: 15rpx;
.doctor-avatar {
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
border-radius: 50%;
}
.consultation-info {
flex: 1;
.doctor-name {
margin-bottom: 8rpx;
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.consultation-time {
margin-bottom: 8rpx;
font-size: 24rpx;
color: #666;
}
.consultation-status {
padding: 5rpx 15rpx;
font-size: 22rpx;
border-radius: 10rpx;
&.completed {
color: #34c759;
background: #e8f5e8;
}
&.ongoing {
color: #ff9500;
background: #fff2e8;
}
&.pending {
color: #007aff;
background: #e8f4fd;
}
}
}
.consultation-type {
.type-icon {
font-size: 30rpx;
}
}
}
}
.quick-consultation {
position: fixed;
right: 30rpx;
bottom: 100rpx;
.quick-btn {
display: flex;
align-items: center;
padding: 20rpx 40rpx;
background: linear-gradient(45deg, #ff6b6b, #ff8e8e);
border: none;
border-radius: 50rpx;
box-shadow: 0 8rpx 20rpx rgba(255, 107, 107, 0.3);
.btn-icon {
margin-right: 10rpx;
font-size: 32rpx;
}
.btn-text {
font-size: 28rpx;
font-weight: bold;
color: white;
}
}
}
</style>

View File

@ -0,0 +1,5 @@
<template></template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,612 @@
<template>
<view class="detection-page">
<!-- 顶部总览 -->
<view class="health-overview">
<view class="overview-header">
<text class="title">健康状态</text>
<text class="date">{{ formatDate(new Date()) }}</text>
</view>
<view class="health-score">
<view class="score-circle">
<text class="score-text">{{ healthScore }}</text>
<text class="score-label">健康评分</text>
</view>
<view class="score-desc">
<text class="status" :class="healthLevel">{{ getHealthStatus(healthScore) }}</text>
<text class="tips">{{ getHealthTips(healthScore) }}</text>
</view>
</view>
</view>
<!-- 快速检测 -->
<view class="quick-test-section">
<view class="section-title">快速检测</view>
<view class="test-grid">
<view v-for="test in quickTests" :key="test.id" class="test-item" @click="startTest(test)">
<view class="test-icon">
<text class="icon">{{ test.icon }}</text>
</view>
<text class="test-name">{{ test.name }}</text>
<text class="test-desc">{{ test.description }}</text>
</view>
</view>
</view>
<!-- 生命体征 -->
<view class="vital-signs">
<view class="section-header">
<text class="section-title">生命体征</text>
<text class="record-btn" @click="recordVitals">记录</text>
</view>
<view class="vital-grid">
<view
v-for="vital in vitalSigns"
:key="vital.type"
class="vital-item"
@click="viewVitalDetail(vital)"
>
<view class="vital-icon">
<text class="icon">{{ vital.icon }}</text>
</view>
<view class="vital-info">
<text class="vital-value">{{ vital.value }}</text>
<text class="vital-unit">{{ vital.unit }}</text>
<text class="vital-label">{{ vital.label }}</text>
</view>
<view class="vital-status" :class="vital.status">
<text class="status-text">{{ getStatusText(vital.status) }}</text>
</view>
</view>
</view>
</view>
<!-- 健康趋势 -->
<view class="health-trends">
<view class="section-title">健康趋势</view>
<view class="trend-chart">
<view class="chart-placeholder">
<text class="chart-text">📊 健康趋势图表</text>
</view>
<view class="trend-summary">
<view class="trend-item">
<text class="trend-label">本周平均</text>
<text class="trend-value">良好</text>
</view>
<view class="trend-item">
<text class="trend-label">改善指标</text>
<text class="trend-value">3</text>
</view>
</view>
</view>
</view>
<!-- 健康报告 -->
<view class="health-reports">
<view class="section-header">
<text class="section-title">健康报告</text>
<text class="more-link" @click="viewAllReports">查看全部</text>
</view>
<view class="report-list">
<view
v-for="report in recentReports"
:key="report.id"
class="report-item"
@click="viewReport(report)"
>
<view class="report-icon">
<text class="icon">📋</text>
</view>
<view class="report-info">
<text class="report-title">{{ report.title }}</text>
<text class="report-date">{{ formatDate(report.date) }}</text>
</view>
<view class="report-score" :class="report.level">
<text class="score">{{ report.score }}</text>
</view>
</view>
</view>
</view>
<!-- 生成报告按钮 -->
<view class="generate-report">
<button class="generate-btn" @click="generateReport">
<text class="btn-icon">📊</text>
<text class="btn-text">生成健康报告</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
const healthScore = ref(85);
const healthLevel = ref("good");
const quickTests = ref([
{ id: 1, name: "血压测量", icon: "🩸", description: "监测血压状况" },
{ id: 2, name: "心率检测", icon: "❤️", description: "实时心率监控" },
{ id: 3, name: "体温测量", icon: "🌡️", description: "体温健康检查" },
{ id: 4, name: "BMI计算", icon: "⚖️", description: "身体质量指数" },
]);
const vitalSigns = ref([
{
type: "blood_pressure",
label: "血压",
value: "120/80",
unit: "mmHg",
icon: "🩸",
status: "normal",
},
{ type: "heart_rate", label: "心率", value: "72", unit: "bpm", icon: "❤️", status: "normal" },
{ type: "temperature", label: "体温", value: "36.5", unit: "°C", icon: "🌡️", status: "normal" },
{ type: "weight", label: "体重", value: "65.0", unit: "kg", icon: "⚖️", status: "normal" },
]);
const recentReports = ref([
{ id: "1", title: "综合健康报告", date: new Date(), score: 85, level: "good" },
{
id: "2",
title: "基础体检报告",
date: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
score: 78,
level: "normal",
},
]);
const startTest = (test: any) => {
uni.navigateTo({ url: `/pages/health/detection/${test.type}?testId=${test.id}` });
};
const recordVitals = () => {
uni.navigateTo({ url: "/pages/health/detection/vitals" });
};
const viewVitalDetail = (vital: any) => {
uni.navigateTo({ url: `/pages/health/detection/vital-detail?type=${vital.type}` });
};
const viewAllReports = () => {
uni.navigateTo({ url: "/pages/health/detection/reports" });
};
const viewReport = (report: any) => {
uni.navigateTo({ url: `/pages/health/detection/report-detail?id=${report.id}` });
};
const generateReport = () => {
uni.showLoading({ title: "生成中..." });
setTimeout(() => {
uni.hideLoading();
uni.navigateTo({ url: "/pages/health/detection/generate-report" });
}, 2000);
};
const formatDate = (date: Date) => {
return date.toLocaleDateString("zh-CN");
};
const getHealthStatus = (score: number) => {
if (score >= 90) return "优秀";
if (score >= 80) return "良好";
if (score >= 70) return "一般";
return "需改善";
};
const getHealthTips = (score: number) => {
if (score >= 90) return "继续保持健康的生活方式";
if (score >= 80) return "整体健康状况良好,建议定期检查";
if (score >= 70) return "需要注意某些健康指标";
return "建议咨询专业医生意见";
};
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
normal: "正常",
warning: "注意",
danger: "异常",
};
return statusMap[status] || status;
};
</script>
<style lang="scss" scoped>
.detection-page {
min-height: 100vh;
padding: 20rpx;
background: #f5f5f5;
}
.health-overview {
padding: 30rpx;
margin-bottom: 30rpx;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
.overview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.title {
font-size: 36rpx;
font-weight: bold;
}
.date {
font-size: 24rpx;
opacity: 0.8;
}
}
.health-score {
display: flex;
align-items: center;
.score-circle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 150rpx;
height: 150rpx;
margin-right: 40rpx;
border: 6rpx solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
.score-text {
font-size: 48rpx;
font-weight: bold;
line-height: 1;
}
.score-label {
margin-top: 5rpx;
font-size: 20rpx;
opacity: 0.8;
}
}
.score-desc {
flex: 1;
.status {
display: block;
margin-bottom: 15rpx;
font-size: 32rpx;
font-weight: bold;
&.good {
color: #34c759;
}
&.normal {
color: #ff9500;
}
&.poor {
color: #ff3b30;
}
}
.tips {
font-size: 24rpx;
line-height: 1.4;
opacity: 0.9;
}
}
}
}
.quick-test-section {
padding: 30rpx;
margin-bottom: 30rpx;
background: white;
border-radius: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.section-title {
margin-bottom: 30rpx;
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.test-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.test-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 30rpx 20rpx;
background: #f8f9fa;
border-radius: 15rpx;
transition: transform 0.3s;
&:active {
transform: scale(0.95);
}
.test-icon {
margin-bottom: 15rpx;
.icon {
font-size: 40rpx;
}
}
.test-name {
margin-bottom: 10rpx;
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.test-desc {
font-size: 22rpx;
color: #666;
text-align: center;
}
}
}
.vital-signs {
padding: 30rpx;
margin-bottom: 30rpx;
background: white;
border-radius: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.record-btn {
padding: 10rpx 20rpx;
font-size: 26rpx;
color: #007aff;
background: #e8f4fd;
border-radius: 15rpx;
}
}
.vital-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.vital-item {
position: relative;
display: flex;
align-items: center;
padding: 25rpx;
background: #f8f9fa;
border-radius: 15rpx;
.vital-icon {
margin-right: 20rpx;
.icon {
font-size: 32rpx;
}
}
.vital-info {
flex: 1;
.vital-value {
margin-right: 5rpx;
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.vital-unit {
font-size: 20rpx;
color: #999;
}
.vital-label {
display: block;
margin-top: 5rpx;
font-size: 24rpx;
color: #666;
}
}
.vital-status {
position: absolute;
top: 10rpx;
right: 10rpx;
padding: 5rpx 10rpx;
font-size: 20rpx;
border-radius: 8rpx;
&.normal {
color: #34c759;
background: #e8f5e8;
}
&.warning {
color: #ff9500;
background: #fff2e8;
}
&.danger {
color: #ff3b30;
background: #ffe8e8;
}
}
}
}
.health-trends {
padding: 30rpx;
margin-bottom: 30rpx;
background: white;
border-radius: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.section-title {
margin-bottom: 30rpx;
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.trend-chart {
.chart-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 200rpx;
margin-bottom: 20rpx;
background: #f8f9fa;
border-radius: 15rpx;
.chart-text {
font-size: 32rpx;
color: #999;
}
}
.trend-summary {
display: flex;
justify-content: space-around;
.trend-item {
text-align: center;
.trend-label {
display: block;
margin-bottom: 10rpx;
font-size: 24rpx;
color: #666;
}
.trend-value {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
}
}
}
}
.health-reports {
padding: 30rpx;
margin-bottom: 30rpx;
background: white;
border-radius: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.more-link {
font-size: 26rpx;
color: #007aff;
}
}
.report-list {
.report-item {
display: flex;
align-items: center;
padding: 25rpx;
margin-bottom: 20rpx;
background: #f8f9fa;
border-radius: 15rpx;
.report-icon {
margin-right: 20rpx;
.icon {
font-size: 32rpx;
}
}
.report-info {
flex: 1;
.report-title {
margin-bottom: 8rpx;
font-size: 28rpx;
color: #333;
}
.report-date {
font-size: 24rpx;
color: #666;
}
}
.report-score {
font-size: 32rpx;
font-weight: bold;
&.good {
color: #34c759;
}
&.normal {
color: #ff9500;
}
&.poor {
color: #ff3b30;
}
}
}
}
}
.generate-report {
padding: 30rpx 0;
.generate-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 25rpx;
background: linear-gradient(45deg, #007aff, #5ac8fa);
border: none;
border-radius: 50rpx;
box-shadow: 0 8rpx 20rpx rgba(0, 122, 255, 0.3);
.btn-icon {
margin-right: 15rpx;
font-size: 32rpx;
}
.btn-text {
font-size: 30rpx;
font-weight: bold;
color: white;
}
}
}
</style>

View File

@ -0,0 +1,5 @@
<template></template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,5 @@
<template></template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,454 @@
<template>
<view class="diet-management">
<!-- 今日营养摄入概览 -->
<view class="nutrition-summary">
<view class="summary-header">
<text class="date">{{ formatDate(currentDate) }}</text>
<text class="tips">营养摄入概览</text>
</view>
<view class="nutrition-circle">
<view class="circle-item">
<view
class="circle-progress"
:style="{
background: `conic-gradient(#4CAF50 0deg ${calorieProgress}deg, #f0f0f0 ${calorieProgress}deg 360deg)`,
}"
>
<view class="circle-content">
<text class="value">{{ todayNutrition.calories }}</text>
<text class="unit">kcal</text>
</view>
</view>
<text class="label">热量</text>
</view>
<view class="nutrients">
<view class="nutrient-item">
<text class="nutrient-name">蛋白质</text>
<text class="nutrient-value">{{ todayNutrition.protein }}g</text>
</view>
<view class="nutrient-item">
<text class="nutrient-name">脂肪</text>
<text class="nutrient-value">{{ todayNutrition.fat }}g</text>
</view>
<view class="nutrient-item">
<text class="nutrient-name">碳水</text>
<text class="nutrient-value">{{ todayNutrition.carbs }}g</text>
</view>
</view>
</view>
</view>
<!-- 中医体质膳食建议 -->
<view class="constitution-diet">
<view class="section-title">
<text>体质膳食建议</text>
<text class="constitution-type">{{ userConstitution }}</text>
</view>
<view class="diet-suggestions">
<view v-for="suggestion in dietSuggestions" :key="suggestion.id" class="suggestion-item">
<image :src="suggestion.image" class="suggestion-image" />
<view class="suggestion-content">
<text class="suggestion-name">{{ suggestion.name }}</text>
<text class="suggestion-desc">{{ suggestion.description }}</text>
<text class="suggestion-effect">{{ suggestion.effect }}</text>
</view>
</view>
</view>
</view>
<!-- 每日膳食记录 -->
<view class="daily-meals">
<view class="section-title">今日膳食</view>
<view class="meal-list">
<view v-for="meal in todayMeals" :key="meal.id" class="meal-item">
<view class="meal-header">
<text class="meal-time">{{ meal.time }}</text>
<text class="meal-calories">{{ meal.calories }}kcal</text>
</view>
<view class="meal-foods">
<text v-for="food in meal.foods" :key="food" class="food-item">{{ food }}</text>
</view>
</view>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="bottom-actions">
<button class="action-btn primary" @click="goToRecord">记录饮食</button>
<button class="action-btn secondary" @click="viewHistory">查看历史</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
interface NutritionData {
calories: number;
protein: number;
fat: number;
carbs: number;
}
interface Meal {
id: string;
time: string;
calories: number;
foods: string[];
}
interface DietSuggestion {
id: string;
name: string;
description: string;
effect: string;
image: string;
}
const currentDate = ref(new Date());
const userConstitution = ref("气虚体质");
const todayNutrition = ref<NutritionData>({
calories: 1650,
protein: 85,
fat: 58,
carbs: 205,
});
const todayMeals = ref<Meal[]>([
{
id: "1",
time: "早餐 8:00",
calories: 420,
foods: ["小米粥", "鸡蛋", "包子", "咸菜"],
},
{
id: "2",
time: "午餐 12:30",
calories: 680,
foods: ["米饭", "红烧肉", "青菜", "冬瓜汤"],
},
{
id: "3",
time: "晚餐 18:00",
calories: 550,
foods: ["面条", "西红柿鸡蛋", "拌黄瓜"],
},
]);
const dietSuggestions = ref<DietSuggestion[]>([
{
id: "1",
name: "山药薏米粥",
description: "健脾益气,适合气虚体质",
effect: "补气健脾,增强体质",
image: "/static/images/diet/yam-porridge.jpg",
},
{
id: "2",
name: "黄芪炖鸡",
description: "补气养血,增强免疫力",
effect: "益气固表,补虚强身",
image: "/static/images/diet/chicken-soup.jpg",
},
{
id: "3",
name: "红枣银耳汤",
description: "滋阴润燥,养血安神",
effect: "补血养颜,润肺止咳",
image: "/static/images/diet/dates-soup.jpg",
},
]);
const calorieProgress = computed(() => {
const targetCalories = 2000;
return Math.min((todayNutrition.value.calories / targetCalories) * 360, 360);
});
const formatDate = (date: Date) => {
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}${day}`;
};
const goToRecord = () => {
uni.navigateTo({
url: "/pages/health/diet/record",
});
};
const viewHistory = () => {
uni.navigateTo({
url: "/pages/health/diet/history",
});
};
onMounted(() => {
// 获取用户体质信息和膳食数据
});
</script>
<style lang="scss" scoped>
.diet-management {
min-height: 100vh;
padding: 20rpx;
background-color: #f8f9fa;
}
.nutrition-summary {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.date {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.tips {
font-size: 24rpx;
color: #666;
}
}
.nutrition-circle {
display: flex;
align-items: center;
justify-content: space-between;
}
.circle-item {
display: flex;
flex-direction: column;
align-items: center;
}
.circle-progress {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 160rpx;
height: 160rpx;
border-radius: 50%;
&::before {
position: absolute;
width: 120rpx;
height: 120rpx;
content: "";
background: white;
border-radius: 50%;
}
}
.circle-content {
position: relative;
z-index: 1;
text-align: center;
.value {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.unit {
font-size: 20rpx;
color: #666;
}
}
.label {
margin-top: 16rpx;
font-size: 24rpx;
color: #666;
}
.nutrients {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.nutrient-item {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 200rpx;
padding: 16rpx 20rpx;
background: #f8f9fa;
border-radius: 8rpx;
.nutrient-name {
font-size: 24rpx;
color: #666;
}
.nutrient-value {
font-size: 26rpx;
font-weight: 600;
color: #333;
}
}
.constitution-diet {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
font-size: 28rpx;
font-weight: 600;
color: #333;
.constitution-type {
padding: 8rpx 16rpx;
font-size: 24rpx;
color: #4caf50;
background: #e8f5e8;
border-radius: 20rpx;
}
}
.diet-suggestions {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.suggestion-item {
display: flex;
align-items: center;
padding: 20rpx;
background: #f8f9fa;
border-radius: 12rpx;
.suggestion-image {
width: 100rpx;
height: 100rpx;
margin-right: 20rpx;
border-radius: 8rpx;
}
.suggestion-content {
flex: 1;
.suggestion-name {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.suggestion-desc {
display: block;
margin-bottom: 8rpx;
font-size: 22rpx;
color: #666;
}
.suggestion-effect {
display: block;
font-size: 20rpx;
color: #4caf50;
}
}
}
.daily-meals {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.meal-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.meal-item {
padding: 20rpx;
background: #f8f9fa;
border-radius: 12rpx;
.meal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
.meal-time {
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.meal-calories {
font-size: 24rpx;
color: #4caf50;
}
}
.meal-foods {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
.food-item {
padding: 6rpx 12rpx;
font-size: 22rpx;
color: #666;
background: white;
border-radius: 16rpx;
}
}
}
.bottom-actions {
display: flex;
gap: 20rpx;
padding: 30rpx 0;
.action-btn {
flex: 1;
height: 88rpx;
font-size: 28rpx;
font-weight: 600;
border: none;
border-radius: 44rpx;
&.primary {
color: white;
background: #4caf50;
}
&.secondary {
color: #666;
background: #f0f0f0;
}
}
}
</style>

View File

@ -0,0 +1,505 @@
<template>
<view class="diet-record">
<!-- 选择餐次 -->
<view class="meal-type-selector">
<view
v-for="type in mealTypes"
:key="type.value"
class="meal-type-item"
:class="{ active: selectedMealType === type.value }"
@click="selectMealType(type.value)"
>
<text>{{ type.label }}</text>
</view>
</view>
<!-- 搜索食物 -->
<view class="search-section">
<view class="search-bar">
<uni-icons type="search" size="18" color="#999"></uni-icons>
<input v-model="searchKeyword" placeholder="搜索食物" @input="searchFood" />
</view>
</view>
<!-- 常用食物 -->
<view v-if="!searchKeyword" class="common-foods">
<view class="section-title">常用食物</view>
<view class="food-grid">
<view
v-for="food in commonFoods"
:key="food.id"
class="food-item"
@click="selectFood(food)"
>
<image :src="food.image" class="food-image" />
<text class="food-name">{{ food.name }}</text>
<text class="food-calories">{{ food.calories }}kcal/100g</text>
</view>
</view>
</view>
<!-- 搜索结果 -->
<view v-if="searchKeyword" class="search-results">
<view class="section-title">搜索结果</view>
<view class="food-list">
<view
v-for="food in searchResults"
:key="food.id"
class="food-item"
@click="selectFood(food)"
>
<image :src="food.image" class="food-image" />
<view class="food-info">
<text class="food-name">{{ food.name }}</text>
<text class="food-nutrition">热量: {{ food.calories }}kcal/100g</text>
</view>
</view>
</view>
</view>
<!-- 已选择的食物 -->
<view v-if="selectedFoods.length > 0" class="selected-foods">
<view class="section-title">已选择食物</view>
<view class="selected-list">
<view v-for="(food, index) in selectedFoods" :key="index" class="selected-item">
<view class="food-info">
<text class="food-name">{{ food.name }}</text>
<text class="food-amount">{{ food.amount }}g</text>
</view>
<view class="amount-controls">
<button class="control-btn" @click="adjustAmount(index, -10)">-</button>
<input v-model.number="food.amount" type="number" class="amount-input" />
<button class="control-btn" @click="adjustAmount(index, 10)">+</button>
</view>
<button class="remove-btn" @click="removeFood(index)">删除</button>
</view>
</view>
<!-- 营养汇总 -->
<view class="nutrition-summary">
<text class="summary-title">营养汇总</text>
<view class="nutrition-row">
<text>总热量: {{ totalNutrition.calories }}kcal</text>
<text>蛋白质: {{ totalNutrition.protein }}g</text>
</view>
<view class="nutrition-row">
<text>脂肪: {{ totalNutrition.fat }}g</text>
<text>碳水: {{ totalNutrition.carbs }}g</text>
</view>
</view>
</view>
<!-- 底部保存按钮 -->
<view v-if="selectedFoods.length > 0" class="bottom-actions">
<button class="save-btn" @click="saveDietRecord">保存记录</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
interface Food {
id: string;
name: string;
calories: number;
protein: number;
fat: number;
carbs: number;
image: string;
}
interface SelectedFood extends Food {
amount: number;
}
interface MealType {
value: string;
label: string;
}
const mealTypes: MealType[] = [
{ value: "breakfast", label: "早餐" },
{ value: "lunch", label: "午餐" },
{ value: "dinner", label: "晚餐" },
{ value: "snack", label: "加餐" },
];
const selectedMealType = ref("breakfast");
const searchKeyword = ref("");
const selectedFoods = ref<SelectedFood[]>([]);
const commonFoods = ref<Food[]>([
{
id: "1",
name: "米饭",
calories: 116,
protein: 2.6,
fat: 0.3,
carbs: 25.9,
image: "/static/images/food/rice.jpg",
},
{
id: "2",
name: "鸡蛋",
calories: 144,
protein: 13.3,
fat: 8.8,
carbs: 2.8,
image: "/static/images/food/egg.jpg",
},
{
id: "3",
name: "鸡胸肉",
calories: 133,
protein: 19.4,
fat: 5.0,
carbs: 2.5,
image: "/static/images/food/chicken.jpg",
},
{
id: "4",
name: "西兰花",
calories: 34,
protein: 4.1,
fat: 0.6,
carbs: 4.3,
image: "/static/images/food/broccoli.jpg",
},
{
id: "5",
name: "苹果",
calories: 52,
protein: 0.2,
fat: 0.2,
carbs: 13.8,
image: "/static/images/food/apple.jpg",
},
{
id: "6",
name: "牛奶",
calories: 54,
protein: 3.0,
fat: 3.2,
carbs: 3.4,
image: "/static/images/food/milk.jpg",
},
]);
const searchResults = ref<Food[]>([]);
const totalNutrition = computed(() => {
return selectedFoods.value.reduce(
(total, food) => {
const ratio = food.amount / 100;
return {
calories: Math.round(total.calories + food.calories * ratio),
protein: Math.round((total.protein + food.protein * ratio) * 10) / 10,
fat: Math.round((total.fat + food.fat * ratio) * 10) / 10,
carbs: Math.round((total.carbs + food.carbs * ratio) * 10) / 10,
};
},
{ calories: 0, protein: 0, fat: 0, carbs: 0 }
);
});
const selectMealType = (type: string) => {
selectedMealType.value = type;
};
const searchFood = () => {
if (!searchKeyword.value) {
searchResults.value = [];
return;
}
// 模拟搜索结果
searchResults.value = commonFoods.value.filter((food) => food.name.includes(searchKeyword.value));
};
const selectFood = (food: Food) => {
const selectedFood: SelectedFood = {
...food,
amount: 100,
};
selectedFoods.value.push(selectedFood);
};
const adjustAmount = (index: number, delta: number) => {
const newAmount = selectedFoods.value[index].amount + delta;
if (newAmount > 0) {
selectedFoods.value[index].amount = newAmount;
}
};
const removeFood = (index: number) => {
selectedFoods.value.splice(index, 1);
};
const saveDietRecord = () => {
// 保存饮食记录
uni.showToast({
title: "记录保存成功",
icon: "success",
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
};
</script>
<style lang="scss" scoped>
.diet-record {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 120rpx;
background-color: #f8f9fa;
}
.meal-type-selector {
display: flex;
padding: 8rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.meal-type-item {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 68rpx;
font-size: 26rpx;
color: #666;
border-radius: 12rpx;
transition: all 0.3s;
&.active {
color: white;
background: #4caf50;
}
}
.search-section {
margin-bottom: 20rpx;
}
.search-bar {
display: flex;
align-items: center;
padding: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
input {
flex: 1;
margin-left: 20rpx;
font-size: 26rpx;
}
}
.section-title {
margin-bottom: 20rpx;
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.common-foods {
margin-bottom: 20rpx;
}
.food-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.food-item {
padding: 20rpx;
text-align: center;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.food-image {
width: 80rpx;
height: 80rpx;
margin-bottom: 12rpx;
border-radius: 8rpx;
}
.food-name {
display: block;
margin-bottom: 8rpx;
font-size: 24rpx;
color: #333;
}
.food-calories {
font-size: 20rpx;
color: #666;
}
}
.search-results {
margin-bottom: 20rpx;
}
.food-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.food-list .food-item {
display: flex;
align-items: center;
padding: 20rpx;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.food-image {
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
border-radius: 8rpx;
}
.food-info {
flex: 1;
.food-name {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
color: #333;
}
.food-nutrition {
font-size: 22rpx;
color: #666;
}
}
}
.selected-foods {
margin-bottom: 20rpx;
}
.selected-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 20rpx;
}
.selected-item {
display: flex;
align-items: center;
padding: 20rpx;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.food-info {
flex: 1;
.food-name {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
color: #333;
}
.food-amount {
font-size: 22rpx;
color: #666;
}
}
.amount-controls {
display: flex;
align-items: center;
margin-right: 20rpx;
.control-btn {
width: 60rpx;
height: 60rpx;
font-size: 28rpx;
color: #666;
background: #f0f0f0;
border: none;
border-radius: 50%;
}
.amount-input {
width: 80rpx;
height: 60rpx;
margin: 0 12rpx;
font-size: 24rpx;
text-align: center;
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
}
}
.remove-btn {
padding: 12rpx 20rpx;
font-size: 22rpx;
color: white;
background: #ff4444;
border: none;
border-radius: 8rpx;
}
}
.nutrition-summary {
padding: 20rpx;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.summary-title {
display: block;
margin-bottom: 16rpx;
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.nutrition-row {
display: flex;
justify-content: space-between;
margin-bottom: 12rpx;
font-size: 24rpx;
color: #666;
}
}
.bottom-actions {
position: fixed;
right: 0;
bottom: 0;
left: 0;
padding: 20rpx;
background: white;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.save-btn {
width: 100%;
height: 88rpx;
font-size: 28rpx;
font-weight: 600;
color: white;
background: #4caf50;
border: none;
border-radius: 44rpx;
}
</style>

View File

@ -0,0 +1,945 @@
<template>
<view class="health-education">
<!-- 轮播图 -->
<view class="banner-section">
<swiper class="banner-swiper" indicator-dots circular autoplay>
<swiper-item v-for="banner in banners" :key="banner.id">
<view class="banner-item" @click="viewContent(banner)">
<image :src="banner.image" class="banner-image" />
<view class="banner-overlay">
<text class="banner-title">{{ banner.title }}</text>
<text class="banner-desc">{{ banner.description }}</text>
</view>
</view>
</swiper-item>
</swiper>
</view>
<!-- 分类标签 -->
<view class="category-tabs">
<scroll-view scroll-x="true" class="tabs-scroll">
<view class="tabs-list">
<view
v-for="tab in educationTabs"
:key="tab.id"
class="tab-item"
:class="{ active: activeTab === tab.id }"
@click="switchTab(tab.id)"
>
<text>{{ tab.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 内容列表 -->
<view class="content-section">
<!-- 专家讲座 -->
<view v-if="activeTab === 'expert'" class="expert-lectures">
<view class="section-header">
<text class="section-title">专家讲座</text>
<text class="section-more" @click="viewMore('expert')">更多</text>
</view>
<view class="lecture-list">
<view
v-for="lecture in expertLectures"
:key="lecture.id"
class="lecture-item"
@click="playVideo(lecture)"
>
<view class="lecture-video">
<image :src="lecture.cover" class="video-cover" />
<view class="play-button">
<uni-icons type="play-filled" size="24" color="#fff"></uni-icons>
</view>
<text class="video-duration">{{ lecture.duration }}</text>
</view>
<view class="lecture-info">
<text class="lecture-title">{{ lecture.title }}</text>
<text class="lecture-expert">{{ lecture.expert }}</text>
<view class="lecture-meta">
<text class="lecture-views">{{ lecture.views }}次观看</text>
<text class="lecture-date">{{ formatDate(lecture.publishDate) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 健康资讯 -->
<view v-if="activeTab === 'news'" class="health-news">
<view class="section-header">
<text class="section-title">健康资讯</text>
<text class="section-more" @click="viewMore('news')">更多</text>
</view>
<view class="news-list">
<view
v-for="news in healthNews"
:key="news.id"
class="news-item"
@click="readArticle(news)"
>
<image :src="news.image" class="news-image" />
<view class="news-content">
<text class="news-title">{{ news.title }}</text>
<text class="news-summary">{{ news.summary }}</text>
<view class="news-meta">
<text class="news-source">{{ news.source }}</text>
<text class="news-time">{{ formatTime(news.publishTime) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 养生知识 -->
<view v-if="activeTab === 'wellness'" class="wellness-knowledge">
<view class="section-header">
<text class="section-title">养生知识</text>
<text class="section-more" @click="viewMore('wellness')">更多</text>
</view>
<view class="knowledge-grid">
<view
v-for="knowledge in wellnessKnowledge"
:key="knowledge.id"
class="knowledge-card"
@click="viewKnowledge(knowledge)"
>
<image :src="knowledge.image" class="knowledge-image" />
<view class="knowledge-info">
<text class="knowledge-title">{{ knowledge.title }}</text>
<text class="knowledge-tag">{{ knowledge.category }}</text>
</view>
</view>
</view>
</view>
<!-- 食疗配方 -->
<view v-if="activeTab === 'diet'" class="diet-recipes">
<view class="section-header">
<text class="section-title">食疗配方</text>
<text class="section-more" @click="viewMore('diet')">更多</text>
</view>
<view class="recipe-list">
<view
v-for="recipe in dietRecipes"
:key="recipe.id"
class="recipe-item"
@click="viewRecipe(recipe)"
>
<image :src="recipe.image" class="recipe-image" />
<view class="recipe-content">
<text class="recipe-title">{{ recipe.title }}</text>
<text class="recipe-effect">{{ recipe.effect }}</text>
<view class="recipe-ingredients">
<text
v-for="ingredient in recipe.ingredients.slice(0, 3)"
:key="ingredient"
class="ingredient"
>
{{ ingredient }}
</text>
<text v-if="recipe.ingredients.length > 3" class="more-ingredients">
{{ recipe.ingredients.length }}
</text>
</view>
<view class="recipe-meta">
<text class="recipe-difficulty">{{ recipe.difficulty }}</text>
<text class="recipe-time">{{ recipe.cookTime }}分钟</text>
</view>
</view>
</view>
</view>
</view>
<!-- 运动指导 -->
<view v-if="activeTab === 'exercise'" class="exercise-guide">
<view class="section-header">
<text class="section-title">运动指导</text>
<text class="section-more" @click="viewMore('exercise')">更多</text>
</view>
<view class="guide-list">
<view
v-for="guide in exerciseGuides"
:key="guide.id"
class="guide-item"
@click="watchGuide(guide)"
>
<view class="guide-video">
<image :src="guide.cover" class="guide-cover" />
<view class="play-icon">
<uni-icons type="play-filled" size="20" color="#fff"></uni-icons>
</view>
</view>
<view class="guide-info">
<text class="guide-title">{{ guide.title }}</text>
<text class="guide-instructor">{{ guide.instructor }}</text>
<view class="guide-tags">
<text v-for="tag in guide.tags" :key="tag" class="guide-tag">{{ tag }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
interface Banner {
id: string;
title: string;
description: string;
image: string;
type: string;
targetId: string;
}
interface ExpertLecture {
id: string;
title: string;
expert: string;
cover: string;
duration: string;
views: number;
publishDate: Date;
videoUrl: string;
}
interface HealthNews {
id: string;
title: string;
summary: string;
image: string;
source: string;
publishTime: Date;
content: string;
}
interface WellnessKnowledge {
id: string;
title: string;
image: string;
category: string;
content: string;
}
interface DietRecipe {
id: string;
title: string;
effect: string;
image: string;
ingredients: string[];
difficulty: string;
cookTime: number;
steps: string[];
}
interface ExerciseGuide {
id: string;
title: string;
instructor: string;
cover: string;
tags: string[];
videoUrl: string;
}
const activeTab = ref("expert");
const educationTabs = [
{ id: "expert", name: "专家讲座" },
{ id: "news", name: "健康资讯" },
{ id: "wellness", name: "养生知识" },
{ id: "diet", name: "食疗配方" },
{ id: "exercise", name: "运动指导" },
];
const banners = ref<Banner[]>([
{
id: "1",
title: "春季养生要点",
description: "著名中医专家详解春季如何调理身体",
image: "/static/images/banner/spring-health.jpg",
type: "video",
targetId: "lecture1",
},
{
id: "2",
title: "中医体质辨识指南",
description: "了解自己的体质,科学调理身体",
image: "/static/images/banner/constitution.jpg",
type: "article",
targetId: "article1",
},
{
id: "3",
title: "食疗养生经典方",
description: "传统食疗配方,日常保健必备",
image: "/static/images/banner/diet-therapy.jpg",
type: "recipe",
targetId: "recipe1",
},
]);
const expertLectures = ref<ExpertLecture[]>([
{
id: "1",
title: "春季养肝护肝的中医方法",
expert: "李教授 - 中医内科专家",
cover: "/static/images/lectures/liver-care.jpg",
duration: "25:30",
views: 12500,
publishDate: new Date("2024-06-20"),
videoUrl: "https://example.com/video1.mp4",
},
{
id: "2",
title: "气虚体质的调理要点",
expert: "王医生 - 中医体质专家",
cover: "/static/images/lectures/qixu-care.jpg",
duration: "18:45",
views: 8900,
publishDate: new Date("2024-06-18"),
videoUrl: "https://example.com/video2.mp4",
},
{
id: "3",
title: "中医穴位按摩入门",
expert: "张医师 - 针灸推拿专家",
cover: "/static/images/lectures/acupoint-massage.jpg",
duration: "32:15",
views: 15600,
publishDate: new Date("2024-06-15"),
videoUrl: "https://example.com/video3.mp4",
},
]);
const healthNews = ref<HealthNews[]>([
{
id: "1",
title: "世界卫生组织发布最新中医药发展报告",
summary: "报告显示中医药在全球范围内的认知度和应用率持续提升",
image: "/static/images/news/who-report.jpg",
source: "健康时报",
publishTime: new Date("2024-06-22"),
content: "详细新闻内容...",
},
{
id: "2",
title: "夏季防暑降温的中医智慧",
summary: "专家建议通过饮食调理和穴位按摩来预防中暑",
image: "/static/images/news/summer-heat.jpg",
source: "中医药报",
publishTime: new Date("2024-06-21"),
content: "详细新闻内容...",
},
{
id: "3",
title: "中医治疗失眠症状有新突破",
summary: "最新研究表明针灸结合中药治疗失眠效果显著",
image: "/static/images/news/insomnia-treatment.jpg",
source: "医学前沿",
publishTime: new Date("2024-06-20"),
content: "详细新闻内容...",
},
]);
const wellnessKnowledge = ref<WellnessKnowledge[]>([
{
id: "1",
title: "四季养生原则",
image: "/static/images/wellness/four-seasons.jpg",
category: "基础理论",
content: "四季养生详细内容...",
},
{
id: "2",
title: "睡眠养生法",
image: "/static/images/wellness/sleep-health.jpg",
category: "生活养生",
content: "睡眠养生详细内容...",
},
{
id: "3",
title: "情志调养",
image: "/static/images/wellness/emotion-care.jpg",
category: "心理健康",
content: "情志调养详细内容...",
},
{
id: "4",
title: "经络养生",
image: "/static/images/wellness/meridian.jpg",
category: "传统疗法",
content: "经络养生详细内容...",
},
]);
const dietRecipes = ref<DietRecipe[]>([
{
id: "1",
title: "银耳莲子汤",
effect: "滋阴润燥,养心安神",
image: "/static/images/recipes/yiner-lotus.jpg",
ingredients: ["银耳", "莲子", "冰糖", "红枣"],
difficulty: "简单",
cookTime: 45,
steps: ["银耳泡发...", "莲子去芯...", "煮制过程..."],
},
{
id: "2",
title: "黄芪当归炖鸡",
effect: "补气养血,增强免疫",
image: "/static/images/recipes/huangqi-chicken.jpg",
ingredients: ["黄芪", "当归", "土鸡", "生姜", "枸杞"],
difficulty: "中等",
cookTime: 90,
steps: ["鸡肉处理...", "药材准备...", "炖制过程..."],
},
{
id: "3",
title: "山药薏米粥",
effect: "健脾益气,祛湿止泻",
image: "/static/images/recipes/yam-barley.jpg",
ingredients: ["山药", "薏米", "大米", "红豆"],
difficulty: "简单",
cookTime: 60,
steps: ["薏米浸泡...", "山药处理...", "煮粥过程..."],
},
]);
const exerciseGuides = ref<ExerciseGuide[]>([
{
id: "1",
title: "太极拳入门教学",
instructor: "陈师傅",
cover: "/static/images/guides/taichi-basic.jpg",
tags: ["太极拳", "入门", "传统运动"],
videoUrl: "https://example.com/taichi1.mp4",
},
{
id: "2",
title: "八段锦完整教程",
instructor: "李教练",
cover: "/static/images/guides/baduanjin.jpg",
tags: ["八段锦", "养生功法", "完整版"],
videoUrl: "https://example.com/baduanjin.mp4",
},
{
id: "3",
title: "五禽戏健身法",
instructor: "王老师",
cover: "/static/images/guides/wuqinxi.jpg",
tags: ["五禽戏", "健身", "模仿动物"],
videoUrl: "https://example.com/wuqinxi.mp4",
},
]);
const switchTab = (tabId: string) => {
activeTab.value = tabId;
};
const formatDate = (date: Date) => {
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}${day}`;
};
const formatTime = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "今天";
if (days === 1) return "昨天";
if (days < 7) return `${days}天前`;
return formatDate(date);
};
const viewContent = (banner: Banner) => {
if (banner.type === "video") {
playVideo({ id: banner.targetId } as ExpertLecture);
} else if (banner.type === "article") {
readArticle({ id: banner.targetId } as HealthNews);
}
};
const playVideo = (lecture: ExpertLecture) => {
uni.navigateTo({
url: `/pages/health/education/video?id=${lecture.id}`,
});
};
const readArticle = (news: HealthNews) => {
uni.navigateTo({
url: `/pages/health/education/article?id=${news.id}`,
});
};
const viewKnowledge = (knowledge: WellnessKnowledge) => {
uni.navigateTo({
url: `/pages/health/education/knowledge?id=${knowledge.id}`,
});
};
const viewRecipe = (recipe: DietRecipe) => {
uni.navigateTo({
url: `/pages/health/education/recipe?id=${recipe.id}`,
});
};
const watchGuide = (guide: ExerciseGuide) => {
uni.navigateTo({
url: `/pages/health/education/guide?id=${guide.id}`,
});
};
const viewMore = (type: string) => {
uni.navigateTo({
url: `/pages/health/education/list?type=${type}`,
});
};
</script>
<style lang="scss" scoped>
.health-education {
min-height: 100vh;
background-color: #f8f9fa;
}
.banner-section {
height: 400rpx;
margin-bottom: 20rpx;
}
.banner-swiper {
height: 100%;
}
.banner-item {
position: relative;
height: 100%;
}
.banner-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-overlay {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 40rpx 30rpx 30rpx;
color: white;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
.banner-title {
display: block;
margin-bottom: 12rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1.3;
}
.banner-desc {
font-size: 24rpx;
line-height: 1.4;
opacity: 0.9;
}
}
.category-tabs {
padding: 20rpx 0;
background: white;
border-bottom: 1rpx solid #f0f0f0;
}
.tabs-scroll {
width: 100%;
}
.tabs-list {
display: flex;
gap: 32rpx;
padding: 0 20rpx;
}
.tab-item {
padding-bottom: 16rpx;
font-size: 28rpx;
color: #666;
white-space: nowrap;
border-bottom: 3rpx solid transparent;
transition: all 0.3s;
&.active {
font-weight: 600;
color: #2196f3;
border-bottom-color: #2196f3;
}
}
.content-section {
padding: 20rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.section-more {
font-size: 24rpx;
color: #2196f3;
}
}
// 专家讲座样式
.lecture-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.lecture-item {
display: flex;
padding: 20rpx;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.lecture-video {
position: relative;
width: 200rpx;
height: 120rpx;
margin-right: 20rpx;
overflow: hidden;
border-radius: 8rpx;
.video-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.play-button {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
transform: translate(-50%, -50%);
}
.video-duration {
position: absolute;
right: 8rpx;
bottom: 8rpx;
padding: 4rpx 8rpx;
font-size: 20rpx;
color: white;
background: rgba(0, 0, 0, 0.7);
border-radius: 4rpx;
}
}
.lecture-info {
flex: 1;
.lecture-title {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 600;
line-height: 1.3;
color: #333;
}
.lecture-expert {
display: block;
margin-bottom: 12rpx;
font-size: 22rpx;
color: #2196f3;
}
.lecture-meta {
display: flex;
justify-content: space-between;
.lecture-views,
.lecture-date {
font-size: 20rpx;
color: #999;
}
}
}
// 健康资讯样式
.news-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.news-item {
display: flex;
padding: 20rpx;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.news-image {
width: 120rpx;
height: 120rpx;
margin-right: 20rpx;
object-fit: cover;
border-radius: 8rpx;
}
.news-content {
flex: 1;
.news-title {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 600;
line-height: 1.3;
color: #333;
}
.news-summary {
display: block;
display: -webkit-box;
margin-bottom: 12rpx;
overflow: hidden;
font-size: 22rpx;
line-height: 1.4;
color: #666;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.news-meta {
display: flex;
justify-content: space-between;
.news-source {
font-size: 20rpx;
color: #2196f3;
}
.news-time {
font-size: 20rpx;
color: #999;
}
}
}
}
// 养生知识样式
.knowledge-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.knowledge-card {
overflow: hidden;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.knowledge-image {
width: 100%;
height: 200rpx;
object-fit: cover;
}
.knowledge-info {
padding: 20rpx;
.knowledge-title {
display: block;
margin-bottom: 8rpx;
font-size: 24rpx;
font-weight: 600;
line-height: 1.3;
color: #333;
}
.knowledge-tag {
padding: 4rpx 12rpx;
font-size: 20rpx;
color: #2196f3;
background: #e3f2fd;
border-radius: 12rpx;
}
}
}
// 食疗配方样式
.recipe-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.recipe-item {
display: flex;
padding: 20rpx;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.recipe-image {
width: 120rpx;
height: 120rpx;
margin-right: 20rpx;
object-fit: cover;
border-radius: 8rpx;
}
.recipe-content {
flex: 1;
.recipe-title {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.recipe-effect {
display: block;
margin-bottom: 12rpx;
font-size: 22rpx;
color: #4caf50;
}
.recipe-ingredients {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 12rpx;
.ingredient,
.more-ingredients {
padding: 4rpx 8rpx;
font-size: 20rpx;
color: #666;
background: #f0f0f0;
border-radius: 8rpx;
}
}
.recipe-meta {
display: flex;
gap: 20rpx;
.recipe-difficulty,
.recipe-time {
font-size: 20rpx;
color: #999;
}
}
}
}
// 运动指导样式
.guide-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.guide-item {
display: flex;
padding: 20rpx;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.guide-video {
position: relative;
width: 120rpx;
height: 120rpx;
margin-right: 20rpx;
overflow: hidden;
border-radius: 8rpx;
.guide-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 40rpx;
height: 40rpx;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
transform: translate(-50%, -50%);
}
}
.guide-info {
flex: 1;
.guide-title {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.guide-instructor {
display: block;
margin-bottom: 12rpx;
font-size: 22rpx;
color: #2196f3;
}
.guide-tags {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
.guide-tag {
padding: 4rpx 8rpx;
font-size: 20rpx;
color: #4caf50;
background: #e8f5e8;
border-radius: 8rpx;
}
}
}
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<view class="encyclopedia">
<!-- 搜索栏 -->
<view class="search-section">
<view class="search-bar">
<uni-icons type="search" size="18" color="#999"></uni-icons>
<input v-model="searchKeyword" placeholder="搜索中医知识..." @input="searchContent" />
</view>
</view>
<!-- 分类导航 -->
<view class="category-nav">
<scroll-view scroll-x="true" class="category-scroll">
<view class="category-list">
<view
v-for="category in categories"
:key="category.id"
class="category-item"
:class="{ active: activeCategory === category.id }"
@click="switchCategory(category.id)"
>
<view class="category-icon" :style="{ backgroundColor: category.color }">
<text>{{ category.name.charAt(0) }}</text>
</view>
<text class="category-name">{{ category.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 内容区域 -->
<view class="content-area">
<!-- 搜索结果 -->
<view v-if="searchKeyword && searchResults.length > 0" class="search-results">
<view class="section-title">搜索结果</view>
<view class="content-list">
<view
v-for="item in searchResults"
:key="item.id"
class="content-item"
@click="viewDetail(item)"
>
<image :src="item.image" class="content-image" />
<view class="content-info">
<text class="content-title">{{ item.title }}</text>
<text class="content-desc">{{ item.description }}</text>
<view class="content-meta">
<text class="content-category">{{ item.categoryName }}</text>
<text class="content-views">{{ item.views }}次阅读</text>
</view>
</view>
</view>
</view>
</view>
<!-- 分类内容 -->
<view v-else class="category-content">
<!-- 热门推荐 -->
<view v-if="activeCategory === 'all'" class="hot-section">
<view class="section-title">热门推荐</view>
<view class="hot-grid">
<view
v-for="item in hotItems"
:key="item.id"
class="hot-item"
@click="viewDetail(item)"
>
<image :src="item.image" class="hot-image" />
<view class="hot-overlay">
<text class="hot-title">{{ item.title }}</text>
<text class="hot-views">{{ item.views }}次阅读</text>
</view>
</view>
</view>
</view>
<!-- 分类列表 -->
<view class="category-section">
<view class="section-title">{{ getCurrentCategoryName() }}</view>
<view class="content-list">
<view
v-for="item in currentContent"
:key="item.id"
class="content-item"
@click="viewDetail(item)"
>
<image :src="item.image" class="content-image" />
<view class="content-info">
<text class="content-title">{{ item.title }}</text>
<text class="content-desc">{{ item.description }}</text>
<view class="content-meta">
<text class="content-date">{{ formatDate(item.publishDate) }}</text>
<text class="content-views">{{ item.views }}次阅读</text>
</view>
</view>
<view class="content-action">
<uni-icons type="right" size="14" color="#999"></uni-icons>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
interface Category {
id: string;
name: string;
color: string;
}
interface EncyclopediaItem {
id: string;
title: string;
description: string;
image: string;
categoryId: string;
categoryName: string;
publishDate: Date;
views: number;
content: string;
}
const searchKeyword = ref("");
const activeCategory = ref("all");
const searchResults = ref<EncyclopediaItem[]>([]);
const categories = ref<Category[]>([
{ id: "all", name: "全部", color: "#2196F3" },
{ id: "herbs", name: "中药材", color: "#4CAF50" },
{ id: "acupoints", name: "穴位", color: "#FF9800" },
{ id: "constitution", name: "体质", color: "#9C27B0" },
{ id: "diet", name: "食疗", color: "#E91E63" },
{ id: "exercise", name: "养生", color: "#00BCD4" },
{ id: "theory", name: "理论", color: "#795548" },
]);
const encyclopediaData = ref<EncyclopediaItem[]>([
{
id: "1",
title: "人参的功效与作用",
description: "人参是名贵中药材,具有大补元气、复脉固脱、补脾益肺等功效",
image: "/static/images/herbs/ginseng.jpg",
categoryId: "herbs",
categoryName: "中药材",
publishDate: new Date("2024-06-20"),
views: 1250,
content: "人参详细介绍...",
},
{
id: "2",
title: "足三里穴位详解",
description: "足三里是人体重要穴位,具有调理脾胃、补中益气的作用",
image: "/static/images/acupoints/zusanli.jpg",
categoryId: "acupoints",
categoryName: "穴位",
publishDate: new Date("2024-06-19"),
views: 980,
content: "足三里穴位详细介绍...",
},
{
id: "3",
title: "气虚体质的调理方法",
description: "气虚体质表现为容易疲劳、气短懒言,需要通过饮食和运动来调理",
image: "/static/images/constitution/qixu.jpg",
categoryId: "constitution",
categoryName: "体质",
publishDate: new Date("2024-06-18"),
views: 1580,
content: "气虚体质调理详细方法...",
},
{
id: "4",
title: "山药薏米粥的制作与功效",
description: "山药薏米粥具有健脾益气、祛湿止泻的功效,适合脾虚湿重者食用",
image: "/static/images/diet/yam-porridge.jpg",
categoryId: "diet",
categoryName: "食疗",
publishDate: new Date("2024-06-17"),
views: 2100,
content: "山药薏米粥制作方法与功效详解...",
},
{
id: "5",
title: "太极拳的养生原理",
description: "太极拳动作柔和缓慢,能够调节气血,增强体质,是很好的养生运动",
image: "/static/images/exercise/taichi-theory.jpg",
categoryId: "exercise",
categoryName: "养生",
publishDate: new Date("2024-06-16"),
views: 875,
content: "太极拳养生原理详细解析...",
},
{
id: "6",
title: "中医五行学说",
description: "五行学说是中医理论的重要组成部分,用于解释人体生理病理现象",
image: "/static/images/theory/wuxing.jpg",
categoryId: "theory",
categoryName: "理论",
publishDate: new Date("2024-06-15"),
views: 650,
content: "五行学说详细介绍...",
},
{
id: "7",
title: "黄芪的药用价值",
description: "黄芪是常用补气药材,具有补气固表、利尿托毒等功效",
image: "/static/images/herbs/huangqi.jpg",
categoryId: "herbs",
categoryName: "中药材",
publishDate: new Date("2024-06-14"),
views: 1100,
content: "黄芪药用价值详解...",
},
{
id: "8",
title: "合谷穴的按摩方法",
description: "合谷穴是手阳明大肠经穴位,有清热解表、镇静止痛的作用",
image: "/static/images/acupoints/hegu.jpg",
categoryId: "acupoints",
categoryName: "穴位",
publishDate: new Date("2024-06-13"),
views: 750,
content: "合谷穴按摩方法与功效...",
},
]);
const hotItems = computed(() => {
return encyclopediaData.value.sort((a, b) => b.views - a.views).slice(0, 4);
});
const currentContent = computed(() => {
if (activeCategory.value === "all") {
return encyclopediaData.value;
}
return encyclopediaData.value.filter((item) => item.categoryId === activeCategory.value);
});
const getCurrentCategoryName = () => {
const category = categories.value.find((cat) => cat.id === activeCategory.value);
return category?.name || "全部";
};
const switchCategory = (categoryId: string) => {
activeCategory.value = categoryId;
searchKeyword.value = "";
searchResults.value = [];
};
const searchContent = () => {
if (!searchKeyword.value.trim()) {
searchResults.value = [];
return;
}
const keyword = searchKeyword.value.toLowerCase();
searchResults.value = encyclopediaData.value.filter(
(item) =>
item.title.toLowerCase().includes(keyword) || item.description.toLowerCase().includes(keyword)
);
};
const formatDate = (date: Date) => {
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}${day}`;
};
const viewDetail = (item: EncyclopediaItem) => {
uni.navigateTo({
url: `/pages/health/encyclopedia/detail?id=${item.id}`,
});
};
</script>
<style lang="scss" scoped>
.encyclopedia {
min-height: 100vh;
background-color: #f8f9fa;
}
.search-section {
padding: 20rpx;
background: white;
border-bottom: 1rpx solid #f0f0f0;
}
.search-bar {
display: flex;
align-items: center;
height: 72rpx;
padding: 0 24rpx;
background: #f8f9fa;
border-radius: 24rpx;
input {
flex: 1;
margin-left: 16rpx;
font-size: 26rpx;
color: #333;
}
}
.category-nav {
padding: 20rpx 0;
background: white;
border-bottom: 1rpx solid #f0f0f0;
}
.category-scroll {
width: 100%;
}
.category-list {
display: flex;
gap: 24rpx;
padding: 0 20rpx;
}
.category-item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 100rpx;
&.active .category-icon {
transform: scale(1.1);
}
&.active .category-name {
font-weight: 600;
color: #2196f3;
}
.category-icon {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
margin-bottom: 8rpx;
font-size: 24rpx;
font-weight: 600;
color: white;
border-radius: 50%;
transition: transform 0.3s;
}
.category-name {
font-size: 22rpx;
color: #666;
transition: all 0.3s;
}
}
.content-area {
padding: 20rpx;
}
.section-title {
margin-bottom: 20rpx;
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.hot-section {
margin-bottom: 30rpx;
}
.hot-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.hot-item {
position: relative;
height: 200rpx;
overflow: hidden;
border-radius: 12rpx;
.hot-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.hot-overlay {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 20rpx;
color: white;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
.hot-title {
display: block;
margin-bottom: 8rpx;
font-size: 24rpx;
font-weight: 600;
line-height: 1.3;
}
.hot-views {
font-size: 20rpx;
opacity: 0.8;
}
}
}
.content-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.content-item {
display: flex;
align-items: center;
padding: 20rpx;
background: white;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s;
&:active {
transform: scale(0.98);
}
.content-image {
width: 120rpx;
height: 120rpx;
margin-right: 20rpx;
object-fit: cover;
border-radius: 8rpx;
}
.content-info {
flex: 1;
.content-title {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 600;
line-height: 1.3;
color: #333;
}
.content-desc {
display: block;
display: -webkit-box;
margin-bottom: 12rpx;
overflow: hidden;
font-size: 22rpx;
line-height: 1.4;
color: #666;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.content-meta {
display: flex;
align-items: center;
justify-content: space-between;
.content-category,
.content-date {
padding: 4rpx 12rpx;
font-size: 20rpx;
color: #2196f3;
background: #e3f2fd;
border-radius: 12rpx;
}
.content-views {
font-size: 20rpx;
color: #999;
}
}
}
.content-action {
display: flex;
align-items: center;
margin-left: 16rpx;
}
}
</style>

View File

@ -0,0 +1,571 @@
<template>
<view class="exercise-management">
<!-- 今日运动概览 -->
<view class="exercise-summary">
<view class="summary-header">
<text class="date">{{ formatDate(currentDate) }}</text>
<text class="weather"> 22°C 适宜运动</text>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value">{{ todayStats.steps }}</text>
<text class="stat-label">步数</text>
<text class="stat-unit"></text>
</view>
<view class="stat-item">
<text class="stat-value">{{ todayStats.calories }}</text>
<text class="stat-label">消耗</text>
<text class="stat-unit">kcal</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ todayStats.duration }}</text>
<text class="stat-label">时长</text>
<text class="stat-unit">分钟</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ todayStats.distance }}</text>
<text class="stat-label">距离</text>
<text class="stat-unit">公里</text>
</view>
</view>
</view>
<!-- 推荐运动 -->
<view class="recommended-exercises">
<view class="section-title">
<text>推荐运动</text>
<text class="constitution-tag">{{ userConstitution }}</text>
</view>
<view class="exercise-cards">
<view
v-for="exercise in recommendedExercises"
:key="exercise.id"
class="exercise-card"
@click="startExercise(exercise)"
>
<image :src="exercise.image" class="exercise-image" />
<view class="exercise-info">
<text class="exercise-name">{{ exercise.name }}</text>
<text class="exercise-desc">{{ exercise.description }}</text>
<view class="exercise-meta">
<text class="exercise-duration">{{ exercise.duration }}分钟</text>
<text class="exercise-calories">{{ exercise.calories }}kcal</text>
</view>
</view>
<view class="exercise-action">
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
</view>
<!-- 运动计划 -->
<view class="exercise-plan">
<view class="section-title">本周计划</view>
<view class="plan-calendar">
<view
v-for="day in weekPlan"
:key="day.date"
class="calendar-day"
:class="{ today: day.isToday, completed: day.completed }"
>
<text class="day-name">{{ day.dayName }}</text>
<text class="day-date">{{ day.date }}</text>
<view v-if="day.exercise" class="day-exercise">
<text>{{ day.exercise }}</text>
</view>
<view class="day-status">
<uni-icons
v-if="day.completed"
type="checkmarkempty"
size="16"
color="#4CAF50"
></uni-icons>
</view>
</view>
</view>
</view>
<!-- 最近运动记录 -->
<view class="recent-records">
<view class="section-title">最近记录</view>
<view class="record-list">
<view v-for="record in recentRecords" :key="record.id" class="record-item">
<view class="record-icon" :style="{ backgroundColor: record.color }">
<text>{{ record.type.charAt(0) }}</text>
</view>
<view class="record-info">
<text class="record-name">{{ record.name }}</text>
<text class="record-time">{{ record.time }}</text>
</view>
<view class="record-stats">
<text class="record-duration">{{ record.duration }}分钟</text>
<text class="record-calories">{{ record.calories }}kcal</text>
</view>
</view>
</view>
</view>
<!-- 底部操作 -->
<view class="bottom-actions">
<button class="action-btn primary" @click="quickRecord">快速记录</button>
<button class="action-btn secondary" @click="viewHistory">查看历史</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
interface ExerciseStats {
steps: number;
calories: number;
duration: number;
distance: number;
}
interface RecommendedExercise {
id: string;
name: string;
description: string;
duration: number;
calories: number;
image: string;
type: string;
}
interface ExerciseRecord {
id: string;
name: string;
type: string;
duration: number;
calories: number;
time: string;
color: string;
}
interface WeekDay {
date: number;
dayName: string;
isToday: boolean;
completed: boolean;
exercise?: string;
}
const currentDate = ref(new Date());
const userConstitution = ref("气虚体质");
const todayStats = ref<ExerciseStats>({
steps: 6542,
calories: 285,
duration: 45,
distance: 4.2,
});
const recommendedExercises = ref<RecommendedExercise[]>([
{
id: "1",
name: "太极拳",
description: "柔和缓慢,适合气虚体质调理",
duration: 30,
calories: 120,
image: "/static/images/exercise/taichi.jpg",
type: "traditional",
},
{
id: "2",
name: "八段锦",
description: "传统养生功法,强身健体",
duration: 20,
calories: 80,
image: "/static/images/exercise/baduanjin.jpg",
type: "traditional",
},
{
id: "3",
name: "散步",
description: "温和有氧运动,促进气血循环",
duration: 40,
calories: 150,
image: "/static/images/exercise/walking.jpg",
type: "cardio",
},
{
id: "4",
name: "瑜伽",
description: "伸展身体,平静心神",
duration: 45,
calories: 180,
image: "/static/images/exercise/yoga.jpg",
type: "flexibility",
},
]);
const weekPlan = ref<WeekDay[]>([
{ date: 18, dayName: "周一", isToday: false, completed: true, exercise: "太极拳" },
{ date: 19, dayName: "周二", isToday: false, completed: true, exercise: "散步" },
{ date: 20, dayName: "周三", isToday: false, completed: false, exercise: "八段锦" },
{ date: 21, dayName: "周四", isToday: true, completed: false, exercise: "瑜伽" },
{ date: 22, dayName: "周五", isToday: false, completed: false, exercise: "散步" },
{ date: 23, dayName: "周六", isToday: false, completed: false, exercise: "太极拳" },
{ date: 24, dayName: "周日", isToday: false, completed: false, exercise: "休息" },
]);
const recentRecords = ref<ExerciseRecord[]>([
{
id: "1",
name: "太极拳练习",
type: "传统运动",
duration: 30,
calories: 120,
time: "今天 08:00",
color: "#4CAF50",
},
{
id: "2",
name: "晨间散步",
type: "有氧运动",
duration: 25,
calories: 95,
time: "昨天 07:30",
color: "#2196F3",
},
{
id: "3",
name: "八段锦",
type: "传统运动",
duration: 20,
calories: 80,
time: "前天 19:00",
color: "#FF9800",
},
]);
const formatDate = (date: Date) => {
const month = date.getMonth() + 1;
const day = date.getDate();
const weekDay = ["日", "一", "二", "三", "四", "五", "六"][date.getDay()];
return `${month}${day}日 周${weekDay}`;
};
const startExercise = (exercise: RecommendedExercise) => {
uni.navigateTo({
url: `/pages/health/exercise/detail?id=${exercise.id}`,
});
};
const quickRecord = () => {
uni.navigateTo({
url: "/pages/health/exercise/record",
});
};
const viewHistory = () => {
uni.navigateTo({
url: "/pages/health/exercise/history",
});
};
</script>
<style lang="scss" scoped>
.exercise-management {
min-height: 100vh;
padding: 20rpx;
background-color: #f8f9fa;
}
.exercise-summary {
padding: 30rpx;
margin-bottom: 20rpx;
color: white;
background: linear-gradient(135deg, #2196f3, #1976d2);
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(33, 150, 243, 0.3);
}
.summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.date {
font-size: 28rpx;
font-weight: 600;
}
.weather {
font-size: 22rpx;
opacity: 0.9;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
}
.stat-item {
text-align: center;
.stat-value {
display: block;
margin-bottom: 8rpx;
font-size: 32rpx;
font-weight: 600;
}
.stat-label {
display: block;
margin-bottom: 4rpx;
font-size: 22rpx;
opacity: 0.8;
}
.stat-unit {
font-size: 18rpx;
opacity: 0.7;
}
}
.recommended-exercises {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
font-size: 28rpx;
font-weight: 600;
color: #333;
.constitution-tag {
padding: 8rpx 16rpx;
font-size: 22rpx;
font-weight: normal;
color: #4caf50;
background: #e8f5e8;
border-radius: 20rpx;
}
}
.exercise-cards {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.exercise-card {
display: flex;
align-items: center;
padding: 20rpx;
background: #f8f9fa;
border-radius: 12rpx;
transition: all 0.3s;
&:active {
transform: scale(0.98);
}
.exercise-image {
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
border-radius: 8rpx;
}
.exercise-info {
flex: 1;
.exercise-name {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.exercise-desc {
display: block;
margin-bottom: 12rpx;
font-size: 22rpx;
color: #666;
}
.exercise-meta {
display: flex;
gap: 20rpx;
.exercise-duration,
.exercise-calories {
font-size: 20rpx;
color: #4caf50;
}
}
}
.exercise-action {
display: flex;
align-items: center;
}
}
.exercise-plan {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.plan-calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12rpx;
}
.calendar-day {
position: relative;
padding: 16rpx 8rpx;
text-align: center;
background: #f8f9fa;
border-radius: 12rpx;
&.today {
background: #e3f2fd;
border: 2rpx solid #2196f3;
}
&.completed {
background: #e8f5e8;
}
.day-name {
display: block;
margin-bottom: 8rpx;
font-size: 20rpx;
color: #666;
}
.day-date {
display: block;
margin-bottom: 8rpx;
font-size: 24rpx;
font-weight: 600;
color: #333;
}
.day-exercise {
margin-bottom: 8rpx;
font-size: 18rpx;
color: #4caf50;
}
.day-status {
position: absolute;
top: 8rpx;
right: 8rpx;
}
}
.recent-records {
padding: 30rpx;
margin-bottom: 20rpx;
background: white;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.record-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.record-item {
display: flex;
align-items: center;
padding: 20rpx;
background: #f8f9fa;
border-radius: 12rpx;
.record-icon {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
margin-right: 20rpx;
font-size: 24rpx;
font-weight: 600;
color: white;
border-radius: 50%;
}
.record-info {
flex: 1;
.record-name {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.record-time {
font-size: 22rpx;
color: #666;
}
}
.record-stats {
text-align: right;
.record-duration {
display: block;
margin-bottom: 4rpx;
font-size: 24rpx;
color: #333;
}
.record-calories {
font-size: 20rpx;
color: #4caf50;
}
}
}
.bottom-actions {
display: flex;
gap: 20rpx;
padding: 30rpx 0;
.action-btn {
flex: 1;
height: 88rpx;
font-size: 28rpx;
font-weight: 600;
border: none;
border-radius: 44rpx;
&.primary {
color: white;
background: #2196f3;
}
&.secondary {
color: #666;
background: #f0f0f0;
}
}
}
</style>

View File

@ -0,0 +1,5 @@
<template></template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,589 @@
<template>
<view class="health-dashboard">
<!-- 顶部状态栏 -->
<view class="health-header">
<view class="user-greeting">
<text class="greeting-text">您好{{ userInfo.name || "用户" }}</text>
<text class="health-score">健康评分{{ healthScore }}/100</text>
</view>
<view class="health-ring">
<view class="ring-progress" :style="{ '--progress': healthScore }">
<text class="ring-text">{{ healthScore }}</text>
</view>
</view>
</view>
<!-- 快捷功能区 -->
<view class="quick-actions">
<scroll-view scroll-x class="action-scroll">
<view class="action-list">
<view
v-for="action in quickActions"
:key="action.id"
class="action-item"
@click="navigateToModule(action.route)"
>
<view class="action-icon">
<text class="icon">{{ action.icon }}</text>
</view>
<text class="action-name">{{ action.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 健康数据概览 -->
<view class="health-overview">
<view class="overview-title">今日健康数据</view>
<view class="overview-grid">
<view
v-for="item in healthData"
:key="item.key"
class="data-item"
@click="navigateToDetail(item.route)"
>
<view class="data-icon">
<text class="icon">{{ item.icon }}</text>
</view>
<view class="data-content">
<view class="data-value-wrapper">
<text class="data-value">{{ item.value }}</text>
<text class="data-unit">{{ item.unit }}</text>
</view>
<text class="data-label">{{ item.label }}</text>
</view>
<view class="data-trend" :class="item.trend">
<text>{{ getTrendText(item.trend) }}</text>
</view>
</view>
</view>
</view>
<!-- 健康提醒 -->
<view v-if="reminders.length > 0" class="health-reminders">
<view class="reminder-title">健康提醒</view>
<view class="reminder-list">
<view
v-for="reminder in reminders"
:key="reminder.id"
class="reminder-item"
@click="handleReminder(reminder)"
>
<view class="reminder-icon" :class="reminder.type">
<text class="icon">{{ getReminderIcon(reminder.type) }}</text>
</view>
<view class="reminder-content">
<text class="reminder-text">{{ reminder.message }}</text>
<text class="reminder-time">{{ formatTime(reminder.time) }}</text>
</view>
<view class="reminder-action">
<button size="mini" type="primary">处理</button>
</view>
</view>
</view>
</view>
<!-- 健康资讯 -->
<view class="health-news">
<view class="news-header">
<text class="news-title">健康资讯</text>
<text class="news-more" @click="navigateToEducation">查看更多</text>
</view>
<view class="news-list">
<view
v-for="article in healthArticles"
:key="article.id"
class="news-item"
@click="navigateToArticle(article.id)"
>
<image
class="news-image"
:src="article.thumbnail || '/static/images/health-default.png'"
mode="aspectFill"
/>
<view class="news-content">
<text class="news-title">{{ article.title }}</text>
<view class="news-meta">
<text class="news-category">{{ article.category }}</text>
<text class="news-time">{{ formatDate(article.publishDate) }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
const healthScore = ref(85);
const quickActions = ref([
{ id: 1, name: "在线问诊", icon: "👩‍⚕️", route: "/pages/health/consultation/index" },
{ id: 2, name: "健康检测", icon: "🩺", route: "/pages/health/detection/index" },
{ id: 3, name: "慢病管理", icon: "💊", route: "/pages/health/chronic/index" },
{ id: 4, name: "膳食管理", icon: "🍎", route: "/pages/health/diet/index" },
{ id: 5, name: "运动管理", icon: "🏃‍♂️", route: "/pages/health/exercise/index" },
{ id: 6, name: "体质辩识", icon: "☯️", route: "/pages/health/constitution/index" },
{ id: 7, name: "中医百科", icon: "📚", route: "/pages/health/encyclopedia/index" },
{ id: 8, name: "健康讲堂", icon: "🎓", route: "/pages/health/education/index" },
]);
const healthData = ref([
{
key: "heart_rate",
label: "心率",
value: "72",
unit: "bpm",
icon: "❤️",
trend: "stable",
route: "/pages/health/detection/vitals",
},
{
key: "blood_pressure",
label: "血压",
value: "120/80",
unit: "mmHg",
icon: "🩸",
trend: "down",
route: "/pages/health/detection/vitals",
},
{
key: "weight",
label: "体重",
value: "65.5",
unit: "kg",
icon: "⚖️",
trend: "up",
route: "/pages/health/detection/vitals",
},
{
key: "steps",
label: "步数",
value: "8,234",
unit: "步",
icon: "👣",
trend: "up",
route: "/pages/health/exercise/record",
},
]);
const reminders = ref([
{
id: 1,
type: "medication",
message: "该服用降压药了",
time: new Date(),
},
{
id: 2,
type: "exercise",
message: "今日运动目标未完成",
time: new Date(),
},
]);
const healthArticles = ref([
{
id: "1",
title: "春季养生小贴士",
category: "养生保健",
publishDate: new Date().toISOString(),
thumbnail: "/static/images/article1.jpg",
},
{
id: "2",
title: "如何科学减肥",
category: "健康生活",
publishDate: new Date().toISOString(),
thumbnail: "/static/images/article2.jpg",
},
]);
const userInfo = ref({ name: "张三" });
const navigateToModule = (route: string) => {
uni.navigateTo({ url: route });
};
const navigateToDetail = (route: string) => {
uni.navigateTo({ url: route });
};
const navigateToEducation = () => {
uni.navigateTo({ url: "/pages/health/education/index" });
};
const navigateToArticle = (articleId: string) => {
uni.navigateTo({ url: `/pages/health/education/articles/detail?id=${articleId}` });
};
const handleReminder = (reminder: any) => {
console.log("处理提醒:", reminder);
};
const getTrendText = (trend: string) => {
switch (trend) {
case "up":
return "↗";
case "down":
return "↘";
default:
return "→";
}
};
const getReminderIcon = (type: string) => {
switch (type) {
case "medication":
return "💊";
case "exercise":
return "🏃";
case "diet":
return "🍎";
default:
return "⏰";
}
};
const formatTime = (time: Date) => {
return time.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("zh-CN");
};
</script>
<style lang="scss" scoped>
.health-dashboard {
min-height: 100vh;
padding: 20rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.health-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
margin-bottom: 30rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20rpx;
.user-greeting {
.greeting-text {
display: block;
margin-bottom: 10rpx;
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.health-score {
font-size: 28rpx;
color: #1aad19;
}
}
.health-ring {
.ring-progress {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
background: conic-gradient(
#1aad19 0deg,
#1aad19 calc(var(--progress) * 3.6deg),
#e0e0e0 calc(var(--progress) * 3.6deg)
);
border-radius: 50%;
&::before {
position: absolute;
width: 80rpx;
height: 80rpx;
content: "";
background: white;
border-radius: 50%;
}
.ring-text {
z-index: 1;
font-size: 28rpx;
font-weight: bold;
color: #333;
}
}
}
}
.quick-actions {
margin-bottom: 30rpx;
.action-scroll {
white-space: nowrap;
}
.action-list {
display: flex;
padding: 0 20rpx;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 140rpx;
padding: 30rpx 20rpx;
margin-right: 20rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20rpx;
transition: transform 0.3s ease;
&:active {
transform: scale(0.95);
}
.action-icon {
margin-bottom: 15rpx;
.icon {
font-size: 40rpx;
}
}
.action-name {
font-size: 24rpx;
color: #333;
text-align: center;
}
}
}
.health-overview {
padding: 30rpx;
margin-bottom: 30rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20rpx;
.overview-title {
margin-bottom: 30rpx;
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.overview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.data-item {
position: relative;
display: flex;
align-items: center;
padding: 25rpx;
background: #f8f9fa;
border-radius: 15rpx;
.data-icon {
margin-right: 20rpx;
.icon {
font-size: 32rpx;
}
}
.data-content {
flex: 1;
.data-value-wrapper {
display: flex;
align-items: baseline;
.data-value {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.data-unit {
margin-left: 5rpx;
font-size: 20rpx;
color: #999;
}
}
.data-label {
display: block;
margin-top: 5rpx;
font-size: 24rpx;
color: #666;
}
}
.data-trend {
position: absolute;
top: 15rpx;
right: 15rpx;
font-size: 24rpx;
&.up {
color: #ff4757;
}
&.down {
color: #1dd1a1;
}
&.stable {
color: #ffa502;
}
}
}
}
.health-reminders {
padding: 30rpx;
margin-bottom: 30rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20rpx;
.reminder-title {
margin-bottom: 30rpx;
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.reminder-item {
display: flex;
align-items: center;
padding: 25rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 15rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.reminder-icon {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
margin-right: 20rpx;
border-radius: 50%;
&.medication {
background: #e8f5e8;
}
&.exercise {
background: #e8f4fd;
}
&.diet {
background: #fff2e8;
}
.icon {
font-size: 30rpx;
}
}
.reminder-content {
flex: 1;
.reminder-text {
display: block;
margin-bottom: 10rpx;
font-size: 28rpx;
color: #333;
}
.reminder-time {
font-size: 24rpx;
color: #999;
}
}
}
}
.health-news {
padding: 30rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20rpx;
.news-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.news-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.news-more {
font-size: 26rpx;
color: #007aff;
}
}
.news-item {
display: flex;
padding: 20rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 15rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
.news-image {
width: 120rpx;
height: 90rpx;
margin-right: 20rpx;
border-radius: 10rpx;
}
.news-content {
flex: 1;
.news-title {
display: -webkit-box;
margin-bottom: 15rpx;
overflow: hidden;
font-size: 28rpx;
line-height: 1.4;
color: #333;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.news-meta {
display: flex;
justify-content: space-between;
.news-category,
.news-time {
font-size: 22rpx;
color: #999;
}
.news-category {
padding: 5rpx 15rpx;
background: #f0f0f0;
border-radius: 10rpx;
}
}
}
}
}
</style>

View File

@ -0,0 +1,10 @@
<template>
<div class="health-index">
<h1>Health Index Page</h1>
<p>Welcome to the health index page!</p>
</div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

309
src/pages/index/index.vue Normal file
View File

@ -0,0 +1,309 @@
<template>
<view style="width: 100%; height: var(--status-bar-height)" />
<view class="home">
<wd-swiper
v-model:current="current"
:list="swiperList"
autoplay
@click="handleClick"
@change="onChange"
/>
<!-- 快捷导航 -->
<wd-grid clickable :column="4" class="mt-2">
<wd-grid-item
v-for="(item, index) in navList"
:key="index"
use-slot
link-type="navigateTo"
:url="item.url"
>
<view class="p-2">
<image class="w-72rpx h-72rpx rounded-8rpx" :src="item.icon" />
</view>
<view class="text">{{ item.title }}</view>
</wd-grid-item>
</wd-grid>
<!-- 通知公告 -->
<wd-notice-bar
text="中医的慢病管理系统 是一个基于 Vue3 + UniApp + TypeScript 的多端慢病管理系统支持Android、IOS、鸿蒙、微信小程序等平台旨在帮助患者更好地管理慢性疾病。 服务器当前维护中,具体上线时间请关注官方通知。"
color="#34D19D"
type="info"
>
<template #prefix>
<wd-tag color="#FAA21E" bg-color="#FAA21E" plain custom-style="margin-right:10rpx">
通知公告
</wd-tag>
</template>
</wd-notice-bar>
<!-- 数据统计 -->
<wd-grid :column="4" :gutter="4">
<wd-grid-item use-slot custom-class="custom-item">
<view class="flex justify-start pl-5">
<view class="flex-center">
<image class="w-80rpx h-80rpx rounded-8rpx" src="/static/icons/blood_pressure.png" />
<view class="ml-5 text-left">
<view class="font-bold">血压</view>
<view class="mt-2">
{{ VitalSigns.bloodPressure.systolic }}/{{ VitalSigns.bloodPressure.diastolic }}
</view>
</view>
</view>
</view>
</wd-grid-item>
<wd-grid-item use-slot custom-class="custom-item">
<view class="flex justify-start pl-5">
<view class="flex-center">
<image class="w-80rpx h-80rpx rounded-8rpx" src="/static/icons/blood_glucose.png" />
<view class="ml-5 text-left">
<view class="font-bold">血糖</view>
<view class="mt-2">{{ VitalSigns.bloodGlucose }}</view>
</view>
</view>
</view>
</wd-grid-item>
<wd-grid-item use-slot custom-class="custom-item">
<view class="flex justify-start pl-5">
<view class="flex-center">
<image class="w-80rpx h-80rpx rounded-8rpx" src="/static/icons/spo2.png" />
<view class="ml-5 text-left">
<view class="font-bold">血氧饱和度</view>
<view class="mt-2">{{ VitalSigns.bloodOxygen }}</view>
</view>
</view>
</view>
</wd-grid-item>
<wd-grid-item use-slot custom-class="custom-item">
<view class="flex justify-start pl-5">
<view class="flex-center">
<image class="w-80rpx h-80rpx rounded-8rpx" src="/static/icons/steps.png" />
<view class="ml-5 text-left">
<view class="font-bold">今日步数</view>
<view class="mt-2">{{ VitalSigns.steps }}</view>
</view>
</view>
</view>
</wd-grid-item>
</wd-grid>
<wd-card>
<template #title>
<view class="flex-between">
<view>健康趋势</view>
<view>
<!-- <wd-radio-group-->
<!-- v-model="recentDaysRange"-->
<!-- shape="button"-->
<!-- inline-->
<!-- @change="handleDataRangeChange"-->
<!-- >-->
<!-- <wd-radio :value="7">近7天</wd-radio>-->
<!-- <wd-radio :value="15">近15天</wd-radio>-->
<!-- </wd-radio-group>-->
</view>
</view>
</template>
<view class="charts-box">
<qiun-data-charts type="area" :chartData="chartData" :opts="chartOpts" />
</view>
</wd-card>
</view>
</template>
<script setup lang="ts">
// import { dayjs } from "wot-design-uni";
import { VitalSignsData } from "@/api/health/detection";
const current = ref<number>(0);
const VitalSigns = ref<VitalSignsData>({
heartRate: 75,
bodyTemperature: 36.5,
weight: 70,
height: 175,
bloodPressure: {
systolic: 120,
diastolic: 80,
},
bloodGlucose: 20,
bloodOxygen: 98,
steps: 9004,
});
const chartData = ref({
categories: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00"], // 时间点
series: [
{
name: "血压(高压)",
color: "#FF6B6B",
data: [120, 118, 125, 130, 128, 122],
},
{
name: "血压(低压)",
color: "#FFA3A3",
data: [80, 78, 82, 85, 83, 79],
},
{
name: "血氧(%)",
color: "#4ECDC4",
data: [98, 97, 96, 99, 98, 97],
},
{
name: "血糖(mmol/L)",
color: "#45B7D1",
data: [5.2, 5.0, 6.1, 5.8, 5.5, 5.3],
},
{
name: "步数(千步)",
color: "#6A6BFF",
data: [0, 0.5, 4.2, 6.7, 8.9, 10.2],
},
],
});
const chartOpts = ref({
padding: [20, 20, 30, 40], // 调整padding确保标签可见
xAxis: {
fontSize: 10,
rotateLabel: true,
rotateAngle: 45, // 增加角度防止重叠
boundaryGap: true, // 让图表从坐标轴开始
},
yAxis: {
disabled: false, // 启用y轴
splitNumber: 5,
data: [
{ min: 0 }, // 步数和血氧从0开始
{ min: 70, max: 100 }, // 血氧范围
{ min: 4, max: 10 }, // 血糖范围
{ min: 70, max: 140 }, // 血压范围
],
gridType: "dash", // 虚线网格
dashLength: 4,
},
dataLabel: true, // 显示数据标签
legend: {
position: "bottom", // 图例放在底部
fontSize: 10,
},
extra: {
area: {
type: "curve",
opacity: 0.2,
addLine: true,
width: 2,
gradient: true,
activeType: "solid", // 高亮时显示实线
animation: true, // 添加动画效果
},
},
});
// 日期范围
const recentDaysRange = ref(7);
const swiperList = ref(["http://115.190.102.167:5324/banner.png"]);
// 快捷导航列表
const navList = reactive([
{
icon: "/static/icons/user.png",
title: "在线问诊",
url: "/pages/health/consultation/index",
prem: "sys:user:query",
},
{
icon: "/static/icons/role.png",
title: "慢病管理",
url: "/pages/health/chronic/index",
prem: "sys:role:query",
},
{
icon: "/static/icons/notice.png",
title: "中医百科",
url: "/pages/health/encyclopedia/index",
prem: "sys:notice:query",
},
{
icon: "/static/icons/setting.png",
title: "健康讲堂",
url: "/pages/health/education/index",
prem: "sys:config:query",
},
]);
function handleClick(e: any) {
console.log(e);
}
function onChange(e: any) {
console.log(e);
}
// 加载访问统计数据
// const loadVisitStatsData = async () => {
// LogAPI.getVisitStats().then((data) => {
// visitStatsData.value = data;
// });
// };
// 加载访问趋势数据
const loadVisitTrendData = () => {
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setDate(endDate.getDate() - recentDaysRange.value + 1);
// const visitTrendQuery = {
// startDate: dayjs(startDate).format("YYYY-MM-DD"),
// endDate: dayjs(endDate).format("YYYY-MM-DD"),
// };
// LogAPI.getVisitTrend(visitTrendQuery).then((data) => {
// const res = {
// categories: data.dates,
// series: [
// {
// name: "访客数(UV)",
// data: data.ipList,
// },
// {
// name: "浏览量(PV)",
// data: data.pvList,
// },
// ],
// };
// chartData.value = JSON.parse(JSON.stringify(res));
// });
};
// // 数据范围变化
// const handleDataRangeChange = ({ value }: { value: number }) => {
// console.log("handleDataRangeChange", value);
// recentDaysRange.value = value;
// loadVisitTrendData();
// };
onReady(() => {
// loadVisitStatsData();
loadVisitTrendData();
});
</script>
<style setup lang="scss">
.home {
padding: 10rpx 10rpx;
:deep(.custom-item) {
height: 80px !important;
}
:deep(.wd-card) {
margin: 10rpx 0 !important;
}
}
.charts-box {
width: 100%;
height: 300px;
}
</style>

393
src/pages/login/index.vue Normal file
View File

@ -0,0 +1,393 @@
<template>
<view class="login-container">
<!-- 背景图 -->
<image src="/static/images/login-bg.svg" mode="aspectFill" class="login-bg" />
<!-- Logo和标题区域 -->
<view class="header">
<image src="/static/logo.png" class="logo" />
<text class="title">中医慢病管理系统</text>
<text class="subtitle">一种中医慢病管理系统实现方案</text>
</view>
<!-- 登录表单区域 -->
<view class="login-card">
<view class="form-wrap">
<wd-form ref="loginFormRef" :model="loginFormData">
<!-- 用户名输入框 -->
<view class="form-item">
<wd-icon name="user" size="22" color="#165DFF" class="input-icon" />
<input v-model="loginFormData.username" class="form-input" placeholder="请输入用户名" />
<wd-icon
v-if="loginFormData.username"
name="close-fill"
size="18"
color="#9ca3af"
class="clear-icon"
@click="loginFormData.username = ''"
/>
</view>
<view class="divider"></view>
<!-- 密码输入框 -->
<view class="form-item">
<wd-icon name="lock-on" size="22" color="#165DFF" class="input-icon" />
<input
v-model="loginFormData.password"
class="form-input"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
placeholder-style="color: #9ca3af; font-weight: normal;"
/>
<wd-icon
:name="showPassword ? 'eye-open' : 'eye-close'"
size="18"
color="#9ca3af"
class="eye-icon"
@click="showPassword = !showPassword"
/>
</view>
<view class="divider"></view>
<!-- 登录按钮 -->
<button
class="login-btn"
:disabled="loading"
:style="loading ? 'opacity: 0.7;' : ''"
@click="handleLogin"
>
登录
</button>
</wd-form>
<!-- 微信登录 -->
<view class="other-login">
<view class="other-login-title">
<view class="line"></view>
<text class="text">其他登录方式</text>
<view class="line"></view>
</view>
<view class="wechat-login" @click="handleWechatLogin">
<view class="wechat-icon-wrapper">
<image src="/static/icons/weixin.png" class="wechat-icon" />
</view>
</view>
</view>
<!-- 底部协议 -->
<view class="agreement">
<text class="text">登录即同意</text>
<text class="link" @click="navigateToUserAgreement">用户协议</text>
<text class="text"></text>
<text class="link" @click="navigateToPrivacy">隐私政策</text>
</view>
</view>
</view>
<wd-toast />
</view>
</template>
<script lang="ts" setup>
import { onLoad } from "@dcloudio/uni-app";
import { type LoginFormData } from "@/api/auth";
import { useUserStore } from "@/store/modules/user";
import { useToast } from "wot-design-uni";
import { ref } from "vue";
const loginFormRef = ref();
const toast = useToast();
const loading = ref(false);
const userStore = useUserStore();
const showPassword = ref(false);
// 登录表单数据
const loginFormData = ref<LoginFormData>({
username: "admin",
password: "123456",
});
// 获取重定向参数
const redirect = ref("");
onLoad((options) => {
if (options) {
redirect.value = options.redirect ? decodeURIComponent(options.redirect) : "/pages/index/index";
} else {
redirect.value = "/pages/index/index";
}
});
// 登录处理
const handleLogin = () => {
if (loading.value) return;
loading.value = true;
userStore
.login(loginFormData.value)
.then(() => userStore.getInfo())
.then(() => {
toast.success("登录成功");
// 检查用户信息是否完整
if (!userStore.isUserInfoComplete()) {
// 信息不完整,跳转到完善信息页面
setTimeout(() => {
uni.navigateTo({
url: `/pages/login/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
});
}, 1000);
} else {
// 否则直接跳转到重定向页面
setTimeout(() => {
uni.reLaunch({
url: redirect.value,
});
}, 1000);
}
})
.catch((error) => {
toast.error(error?.message || "登录失败");
})
.finally(() => {
loading.value = false;
});
};
// 微信登录处理
const handleWechatLogin = async () => {
if (loading.value) return;
loading.value = true;
try {
// #ifdef MP-WEIXIN
// 获取微信登录的临时 code
const { code } = await uni.login({
provider: "weixin",
});
// 调用后端接口进行登录认证
const result = await userStore.loginByWechat(code);
if (result) {
// 获取用户信息
await userStore.getInfo();
toast.success("登录成功");
// 检查用户信息是否完整
if (!userStore.isUserInfoComplete()) {
// 如果信息不完整,跳转到完善信息页面
setTimeout(() => {
uni.navigateTo({
url: `/pages/login/complete-profile?redirect=${encodeURIComponent(redirect.value)}`,
});
}, 1000);
} else {
// 否则直接跳转到重定向页面
setTimeout(() => {
uni.reLaunch({
url: redirect.value,
});
}, 1000);
}
}
// #endif
// #ifndef MP-WEIXIN
toast.error("当前环境不支持微信登录");
// #endif
} catch (error: any) {
toast.error(error?.message || "微信登录失败");
} finally {
loading.value = false;
}
};
// 跳转到用户协议页面
const navigateToUserAgreement = () => {
uni.navigateTo({
url: "/pages/mine/user-agreement/index",
});
};
// 跳转到隐私政策页面
const navigateToPrivacy = () => {
uni.navigateTo({
url: "/pages/mine/privacy/index",
});
};
</script>
<style lang="scss" scoped>
.login-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
overflow: hidden;
}
.login-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.header {
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 120rpx;
}
.logo {
width: 140rpx;
height: 140rpx;
margin-bottom: 20rpx;
}
.title {
margin-bottom: 10rpx;
font-size: 48rpx;
font-weight: bold;
color: #ffffff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.subtitle {
font-size: 28rpx;
color: #ffffff;
text-align: center;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.login-card {
z-index: 2;
display: flex;
flex-direction: column;
width: 90%;
margin-top: 80rpx;
overflow: hidden;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 24rpx;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.1);
}
.form-wrap {
padding: 40rpx;
}
.form-item {
position: relative;
display: flex;
align-items: center;
padding: 24rpx 0;
}
.input-icon {
margin-right: 20rpx;
}
.form-input {
flex: 1;
height: 60rpx;
font-size: 28rpx;
line-height: 60rpx;
color: #333;
}
.clear-icon,
.eye-icon {
padding: 10rpx;
}
.divider {
height: 1px;
margin: 0;
background-color: rgba(0, 0, 0, 0.06);
}
.login-btn {
width: 100%;
height: 90rpx;
margin-top: 60rpx;
font-size: 32rpx;
line-height: 90rpx;
color: #fff;
background: linear-gradient(90deg, #165dff, #4080ff);
border: none;
border-radius: 45rpx;
box-shadow: 0 8rpx 20rpx rgba(22, 93, 255, 0.3);
transition: all 0.3s;
}
.login-btn:active {
box-shadow: 0 4rpx 10rpx rgba(22, 93, 255, 0.2);
transform: translateY(2rpx);
}
.other-login {
margin-top: 60rpx;
}
.other-login-title {
display: flex;
align-items: center;
margin-bottom: 40rpx;
}
.line {
flex: 1;
height: 1px;
background-color: rgba(0, 0, 0, 0.08);
}
.text {
padding: 0 30rpx;
font-size: 26rpx;
color: #9ca3af;
}
.wechat-login {
display: flex;
justify-content: center;
margin-bottom: 30rpx;
}
.wechat-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 90rpx;
height: 90rpx;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.wechat-icon {
width: 60rpx;
height: 60rpx;
}
.agreement {
display: flex;
justify-content: center;
margin-top: 30rpx;
font-size: 24rpx;
}
.agreement .text {
padding: 0 4rpx;
color: #9ca3af;
}
.agreement .link {
color: #165dff;
}
</style>

View File

@ -0,0 +1,200 @@
<template>
<view class="about-container">
<!-- 顶部 Logo 区域 -->
<view class="logo-section">
<image class="logo" src="/static/logo.png" mode="aspectFit" />
<text class="app-name">vue-uniapp-template</text>
<text class="version">版本 {{ version }}</text>
</view>
<!-- 公司信息区域 -->
<view class="company-info">
<text class="company-name">有来开源组织</text>
<view class="divider" />
<text class="company-desc">专注于快速构建和高效开发的应用解决方案</text>
</view>
<!-- 信息列表 -->
<view class="info-list">
<view class="list-header">
<text class="header-title">优质项目</text>
</view>
<view class="info-item">
<view class="item-content">
<text class="item-label">vue3-element-admin</text>
<text class="item-desc">
基于 Vue3 + Vite5+ TypeScript5 + Element-Plus + Pinia
等主流技术栈构建的免费开源的中后台管理的前端模板
</text>
</view>
</view>
<view class="info-item">
<view class="item-content">
<text class="item-label">vue-uniapp-template</text>
<text class="item-desc">
基于 uni-app + Vue 3 + TypeScript 的项目集成了 ESLintPrettierStylelintHusky
Commitlint 等工具确保代码规范与质量
</text>
</view>
</view>
<view class="info-item">
<view class="item-content">
<text class="item-label">youlai-boot</text>
<text class="item-desc">
基于 JDK 17Spring Boot 3Spring Security 6JWTRedisMybatis-PlusKnife4jVue
3Element-Plus 构建的前后端分离单体权限管理系统
</text>
</view>
</view>
</view>
<!-- 底部版权信息 -->
<view class="copyright">
<text>Copyright © {{ getYear() }} 有来开源组织</text>
<text>All Rights Reserved</text>
</view>
</view>
</template>
<script lang="ts" setup>
const version = ref("1.0.0");
const getYear = () => {
return new Date().getFullYear();
};
onMounted(() => {
// #ifdef MP-WEIXIN
version.value = uni.getSystemInfoSync().appVersion;
// #endif
});
</script>
<style lang="scss" scoped>
.about-container {
min-height: 100vh;
padding: 20px;
background-color: #f5f5f5;
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px 0;
.logo {
width: 80px;
height: 80px;
margin-bottom: 15px;
}
.app-name {
margin-bottom: 8px;
font-size: 20px;
font-weight: bold;
}
.version {
font-size: 14px;
color: #666;
}
}
.company-info {
padding: 30px;
margin: 20px 0;
text-align: center;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.company-name {
margin-bottom: 15px;
font-size: 20px;
font-weight: 600;
color: #333;
}
.divider {
width: 40px;
height: 2px;
margin: 15px auto;
background-color: #409eff;
}
.company-desc {
padding: 0 10px;
margin-top: 15px;
overflow: hidden;
font-size: 15px;
line-height: 1.6;
color: #666;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.info-list {
margin: 20px 0;
background-color: #fff;
border-radius: 8px;
.list-header {
padding: 15px 20px;
border-bottom: 1px solid #eee;
.header-title {
font-size: 17px;
font-weight: 600;
color: #333;
}
}
.info-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
border-bottom: 1px solid #eee;
&:last-child {
border-bottom: none;
}
.item-content {
flex: 1;
padding-right: 15px;
}
.item-label {
display: block;
margin-bottom: 4px;
font-size: 16px;
color: #333;
}
.item-desc {
display: block;
font-size: 13px;
line-height: 1.4;
color: #999;
}
.item-value {
font-size: 16px;
color: #999;
}
}
}
.copyright {
padding: 20px 0;
text-align: center;
text {
display: block;
font-size: 12px;
line-height: 1.5;
color: #999;
}
}
</style>

View File

@ -0,0 +1,154 @@
<template>
<view class="faq-container">
<view class="wechat">
<view class="tips">
<text>长按关注有来技术公众号获取交流群二维码</text>
</view>
<view class="flex-center">
<image
class="w-158px h-158px"
:show-menu-by-longpress="true"
src="/static/images/qrcode-official.png"
mode="aspectFit"
/>
</view>
<view>
<text>如果交流群的二维码过期请加微信(</text>
<text :user-select="true" :selectable="true">haoxianrui</text>
<text>)并备注前端后端全栈以获取最新二维码</text>
</view>
<view>
<text>为确保交流群质量防止营销广告人群混入我们采取了此措施望各位理解</text>
</view>
</view>
<wd-collapse v-model="value">
<wd-collapse-item title="开源项目issues" name="item1">
<!-- #ifdef H5 -->
<a href="https://gitee.com/youlaiorg/vue-uniapp-template/issues">#issues</a>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<text :user-select="true">https://gitee.com/youlaiorg/vue-uniapp-template/issues</text>
<!-- #endif -->
</wd-collapse-item>
<wd-collapse-item title="小程序分包" name="item2">
<view>
<text>
分包主要是因为小程序平台对主包大小有限制微信小程序的规则是主包不超过2M每个分包不超过2M总体积一共不能超过20M
分包不需要按照业务模块来分可以将多个业务模块放入一个分包中直到这个分包达到小程序的大小限制才考虑下一个分包
uniapp的用法与微信官方文档一样具体参见
</text>
<!-- #ifdef H5 -->
<a
href="https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages/basic.html"
>
微信官方文档-分包
</a>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<text :user-select="true" :selectable="true">
https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages/basic.html
</text>
<!-- #endif -->
</view>
<view class="mt-15rpx">
<text>
以下是一个简单示例以下示例中创建了两个分包分包a中包含两个页面分包b中包含一个页面
</text>
<text class="mt-15rpx">
请注意如果想把分包页面中使用的组件打包到分包中则需要将组件放入对应的分包目录下否则组件会被打包到主包中
</text>
</view>
<view class="mt-15rpx">
<text>目录结构</text>
</view>
<rich-text :nodes="subListStr" />
<view class="mt-15rpx">
<text>在pages.json文件中声明分包结构</text>
</view>
<rich-text :nodes="pagesStr" />
</wd-collapse-item>
</wd-collapse>
</view>
</template>
<script lang="ts" setup>
const value = ref<string[]>(["item1"]);
const subListStr = ref<string>(`
<pre style="background-color: #f9f9fa"><code>
|-- components //主包组件目录
|-- pages //主包页面目录
| |-- index
|-- sub-pkg-a
| |-- components //分包组件目录
| |-- pages //分包页面目录
| | |-- cat
| | |-- dog
|-- sub-pkg-b
| |-- components //分包组件目录
| |-- pages //分包页面目录
| | |-- apple</code></pre>
`);
const pagesStr = ref<string>(`<pre style="background-color: #f9f9fa"><code>
{
"pages":[
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "主页"
}
}
],
"subPackages": [
{
"root": "sub-pkg-a",
"pages": [
{
"path": "pages/cat",
"style": {
"navigationBarTitleText": "cat"
}
},
{
"path": "pages/dog",
"style": {
"navigationBarTitleText": "dog"
}
}
]
},
{
"root": "sub-pkg-b",
"pages": [
{
"path": "pages/apple",
"style": {
"navigationBarTitleText": "apple"
}
}
]
}
]
}</code></pre>
`);
onMounted(() => {});
</script>
<style lang="scss" scoped>
.faq-container {
min-height: 100vh;
background-color: #f5f5f5;
.wechat {
padding: 30rpx;
margin: 20px 0;
font-size: 14px;
color: var(--wot-card-content-color, rgba(0, 0, 0, 0.45));
background-color: #fff;
.tips {
font-weight: bold;
text-align: center;
}
}
}
</style>

View File

@ -0,0 +1,193 @@
<template>
<view class="feedback-container">
<!-- 问题类型选择 -->
<wd-cell-group title="问题类型" border>
<view class="radio-group">
<label v-for="item in feedbackTypes" :key="item.value" class="radio-item">
<text class="radio-text">{{ item.label }}</text>
<radio
:value="item.value"
:checked="feedbackType === item.value"
color="#0083ff"
class="radio-button"
style="transform: scale(0.8)"
@click="handleRadioChange(item.value)"
/>
</label>
</view>
</wd-cell-group>
<!-- 问题描述 -->
<wd-cell-group title="问题描述" border>
<wd-textarea
v-model="description"
placeholder="请详细描述您遇到的问题或建议..."
:maxlength="500"
show-count
:rows="5"
/>
</wd-cell-group>
<!-- 图片上传 -->
<wd-cell-group title="相关截图(选填)" border>
<view class="upload-box">
<wd-upload
v-model="fileList"
:max-count="3"
:before-read="beforeRead"
@delete="handleDelete"
/>
</view>
</wd-cell-group>
<!-- 联系方式 -->
<wd-cell-group title="联系方式(选填)" border>
<wd-input v-model="contact" placeholder="请输入您的手机号或邮箱" clearable />
</wd-cell-group>
<!-- 提交按钮 -->
<view class="submit-btn">
<wd-button type="primary" block :loading="submitting" @click="handleSubmit">
提交反馈
</wd-button>
</view>
<wd-toast />
</view>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { useToast } from "wot-design-uni";
import { checkLogin } from "@/utils/auth";
const toast = useToast();
// 检查登录状态
onLoad(() => {
if (!checkLogin()) return;
});
// 问题类型选项
const feedbackTypes = [
{ label: "功能异常", value: "bug" },
{ label: "优化建议", value: "suggestion" },
{ label: "其他问题", value: "other" },
];
// 表单数据
const feedbackType = ref("bug");
const description = ref("");
const fileList = ref<any[]>([]);
const contact = ref("");
const submitting = ref(false);
// 图片上传前的校验
const beforeRead = (file: any) => {
// 验证文件类型
const validTypes = ["image/jpeg", "image/png", "image/gif"];
if (!validTypes.includes(file.type)) {
toast.error("请上传 jpg、png 或 gif 格式的图片");
return false;
}
// 验证文件大小(限制为 5MB
if (file.size > 5 * 1024 * 1024) {
toast.error("图片大小不能超过 5MB");
return false;
}
return true;
};
// 删除图片
const handleDelete = (detail: any) => {
const index = detail.index;
fileList.value.splice(index, 1);
};
// 处理单选框变化
const handleRadioChange = (value: string) => {
feedbackType.value = value;
};
// 提交反馈
const handleSubmit = async () => {
// 表单验证
if (!description.value.trim()) {
toast.error("请描述您遇到的问题");
return;
}
submitting.value = true;
try {
// TODO: 调用提交反馈的接口
await new Promise((resolve) => setTimeout(resolve, 1500)); // 模拟提交
toast.success("提交成功");
// 重置表单
description.value = "";
fileList.value = [];
contact.value = "";
// 延迟返回上一页
setTimeout(() => {
uni.navigateBack();
}, 1500);
} catch {
toast.error("提交失败,请重试");
} finally {
submitting.value = false;
}
};
</script>
<style lang="scss" scoped>
.feedback-container {
min-height: 100vh;
padding: 20rpx 0;
background-color: #f5f5f5;
:deep(.wd-cell-group__title) {
padding: 20rpx 30rpx 10rpx;
font-size: 28rpx;
color: #666;
}
.radio-group {
padding: 4rpx 0;
background-color: #fff;
}
.radio-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10rpx 30rpx;
border-bottom: 1px solid #eee;
&:last-child {
border-bottom: none;
}
}
.radio-text {
font-size: 22rpx;
color: #333;
}
.upload-box {
padding: 20rpx 30rpx;
}
.submit-btn {
margin: 40rpx 30rpx;
}
:deep(.wd-textarea) {
padding: 20rpx 30rpx;
background-color: #fff;
}
.radio-button {
margin-right: -8rpx;
}
}
</style>

582
src/pages/mine/index.vue Normal file
View File

@ -0,0 +1,582 @@
<template>
<view class="mine-container">
<!-- 用户信息卡片 -->
<view class="user-profile">
<view class="blur-bg"></view>
<view class="user-info">
<view class="avatar-container" @click="navigateToProfile">
<image
class="avatar"
:src="isLogin ? userInfo!.avatar : defaultAvatar"
mode="aspectFill"
/>
</view>
<view class="user-details">
<block v-if="isLogin">
<view class="nickname">{{ userInfo!.nickname || "匿名用户" }}</view>
<view class="user-id">ID: {{ userInfo?.username || "0000000" }}</view>
</block>
<block v-else>
<view class="login-prompt">立即登录获取更多功能</view>
<wd-button
custom-class="btn-login"
size="small"
type="primary"
@click="navigateToLoginPage"
>
登录/注册
</wd-button>
</block>
</view>
<view class="actions">
<view class="action-btn" @click="navigateToSettings">
<wd-icon name="setting1" size="22" color="#333" />
</view>
<view v-if="isLogin" class="action-btn" @click="navigateToSection('messages')">
<wd-icon name="notification" size="22" color="#333" />
<view v-if="true" class="badge">2</view>
</view>
</view>
</view>
</view>
<!-- &lt;!&ndash; 数据统计 &ndash;&gt;-->
<!-- <view class="stats-container">-->
<!-- <view class="stat-item" @click="navigateToSection('wallet')">-->
<!-- <view class="stat-value">0.00</view>-->
<!-- <view class="stat-label">我的余额</view>-->
<!-- </view>-->
<!-- <view class="divider"></view>-->
<!-- <view class="stat-item" @click="navigateToSection('favorites')">-->
<!-- <view class="stat-value">0</view>-->
<!-- <view class="stat-label">我的收藏</view>-->
<!-- </view>-->
<!-- <view class="divider"></view>-->
<!-- <view class="stat-item" @click="navigateToSection('history')">-->
<!-- <view class="stat-value">0</view>-->
<!-- <view class="stat-label">浏览历史</view>-->
<!-- </view>-->
<!-- </view>-->
<!-- 常用工具 -->
<view class="card-container">
<view class="card-header">
<view class="card-title">
<wd-icon name="tools" size="18" :color="themeStore.primaryColor" />
<text>常用工具</text>
</view>
</view>
<view class="tools-grid">
<view class="tool-item" @click="navigateToProfile">
<view class="tool-icon">
<wd-icon name="user" size="24" :color="themeStore.primaryColor" />
</view>
<view class="tool-label">个人资料</view>
</view>
<view class="tool-item" @click="navigateToFAQ">
<view class="tool-icon">
<wd-icon name="help-circle" size="24" :color="themeStore.primaryColor" />
</view>
<view class="tool-label">常见问题</view>
</view>
<view class="tool-item" @click="handleQuestionFeedback">
<view class="tool-icon">
<wd-icon name="check-circle" size="24" :color="themeStore.primaryColor" />
</view>
<view class="tool-label">问题反馈</view>
</view>
<view class="tool-item" @click="navigateToAbout">
<view class="tool-icon">
<wd-icon name="info-circle" size="24" :color="themeStore.primaryColor" />
</view>
<view class="tool-label">关于我们</view>
</view>
</view>
</view>
<!-- &lt;!&ndash; 推荐服务 &ndash;&gt;-->
<!-- <view class="card-container">-->
<!-- <view class="card-header">-->
<!-- <view class="card-title">-->
<!-- <wd-icon name="star" size="18" :color="themeStore.primaryColor" />-->
<!-- <text>推荐服务</text>-->
<!-- </view>-->
<!-- </view>-->
<!-- <view class="services-list">-->
<!-- <view class="service-item" @click="navigateToSection('services', 'vip')">-->
<!-- <view class="service-left">-->
<!-- <view class="service-icon">-->
<!-- <wd-icon name="dong" size="22" :color="themeStore.primaryColor" />-->
<!-- </view>-->
<!-- <view class="service-info">-->
<!-- <view class="service-name">会员中心</view>-->
<!-- <view class="service-desc">解锁更多特权</view>-->
<!-- </view>-->
<!-- </view>-->
<!-- <wd-icon name="arrow-right" size="14" color="#999" />-->
<!-- </view>-->
<!-- <view class="service-item" @click="navigateToSection('services', 'coupon')">-->
<!-- <view class="service-left">-->
<!-- <view class="service-icon">-->
<!-- <wd-icon name="discount" size="22" :color="themeStore.primaryColor" />-->
<!-- </view>-->
<!-- <view class="service-info">-->
<!-- <view class="service-name">优惠券</view>-->
<!-- <view class="service-desc">查看我的优惠券</view>-->
<!-- </view>-->
<!-- </view>-->
<!-- <wd-icon name="arrow-right" size="14" color="#999" />-->
<!-- </view>-->
<!-- <view class="service-item" @click="navigateToSection('services', 'invite')">-->
<!-- <view class="service-left">-->
<!-- <view class="service-icon">-->
<!-- <wd-icon name="share" size="22" :color="themeStore.primaryColor" />-->
<!-- </view>-->
<!-- <view class="service-info">-->
<!-- <view class="service-name">邀请有礼</view>-->
<!-- <view class="service-desc">邀请好友得奖励</view>-->
<!-- </view>-->
<!-- </view>-->
<!-- <wd-icon name="arrow-right" size="14" color="#999" />-->
<!-- </view>-->
<!-- </view>-->
<!-- </view>-->
<view v-if="isLogin" class="logout-btn-container">
<wd-button custom-class="logout-btn-unocss" @click="handleLogout">退出登录</wd-button>
</view>
<wd-toast />
</view>
</template>
<script lang="ts" setup>
import { useToast } from "wot-design-uni";
import { useUserStore } from "@/store/modules/user";
import { useThemeStore } from "@/store/modules/theme";
import { computed } from "vue";
const toast = useToast();
const userStore = useUserStore();
const themeStore = useThemeStore();
const userInfo = computed(() => userStore.userInfo);
const isLogin = computed(() => !!userInfo.value);
const defaultAvatar = "/static/images/default-avatar.png";
// 登录
const navigateToLoginPage = () => {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const currentPagePath = `/${currentPage.route}`;
uni.navigateTo({
url: `/pages/login/index?redirect=${encodeURIComponent(currentPagePath)}`,
});
};
// 退出登录
const handleLogout = () => {
uni.showModal({
title: "提示",
content: "确认退出登录吗?",
success: function (res) {
if (res.confirm) {
userStore.logout();
toast.show("已退出登录");
}
},
});
};
// 个人信息
const navigateToProfile = () => {
if (!isLogin.value) {
navigateToLoginPage();
return;
}
uni.navigateTo({ url: "/pages/mine/profile/index" });
};
// 常见问题
const navigateToFAQ = () => {
uni.navigateTo({ url: "/pages/mine/faq/index" });
};
// 关于我们
const navigateToAbout = () => {
uni.navigateTo({ url: "/pages/mine/about/index" });
};
// 设置
const navigateToSettings = () => {
uni.navigateTo({ url: "/pages/mine/settings/index" });
};
// 问题反馈
const handleQuestionFeedback = () => {
uni.navigateTo({ url: "/pages/mine/feedback/index" });
};
// 导航到各个板块
const navigateToSection = (section: string, subSection?: string) => {
if (!isLogin.value && section !== "services") {
navigateToLoginPage();
return;
}
const sections: Record<string, string> = {
messages: "消息中心",
todos: "待办事项",
favorites: "我的收藏",
history: "浏览历史",
wallet: "我的钱包",
orders: "我的订单",
address: "收货地址",
services: "增值服务",
};
let message = sections[section];
if (subSection) {
message += ` - ${subSection}`;
}
toast.show(`${message}功能开发中...`);
};
</script>
<style lang="scss" scoped>
/* stylelint-disable declaration-property-value-no-unknown */
.mine-container {
min-height: 100vh;
padding-bottom: 100rpx;
background-color: #f5f7fa;
}
// 用户信息卡片
.user-profile {
position: relative;
padding: 30rpx;
overflow: hidden;
.blur-bg {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 0;
height: 240rpx;
background: linear-gradient(to bottom, var(--primary-color), var(--primary-color-light));
}
.user-info {
position: relative;
z-index: 1;
display: flex;
align-items: center;
.avatar-container {
position: relative;
.avatar {
width: 120rpx;
height: 120rpx;
border: 4rpx solid rgba(255, 255, 255, 0.8);
border-radius: 50%;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.1);
}
}
.user-details {
flex: 1;
margin-left: 24rpx;
.nickname {
margin-bottom: 8rpx;
font-size: 34rpx;
font-weight: bold;
color: #fff;
}
.user-id {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.login-prompt {
margin-bottom: 16rpx;
font-size: 28rpx;
color: #fff;
}
}
.actions {
display: flex;
.action-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
margin-left: 16rpx;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
.badge {
position: absolute;
top: -6rpx;
right: -6rpx;
z-index: 2;
min-width: 32rpx;
height: 32rpx;
padding: 0 6rpx;
font-size: 20rpx;
line-height: 32rpx;
color: #fff;
text-align: center;
background-color: #ff4d4f;
border: 2rpx solid #fff;
border-radius: 16rpx;
}
}
}
}
}
// 数据统计
.stats-container {
display: flex;
padding: 30rpx 20rpx;
margin: 20rpx 30rpx;
background: #fff;
border-radius: 16rpx;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.04);
.stat-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
.stat-value {
margin-bottom: 8rpx;
font-size: 36rpx;
font-weight: 600;
color: #333;
}
.stat-label {
font-size: 26rpx;
color: #666;
}
}
.divider {
width: 1px;
margin: 0 20rpx;
background-color: #eee;
}
}
// 卡片容器通用样式
.card-container {
margin: 24rpx 30rpx;
overflow: hidden;
background: #fff;
border-radius: 16rpx;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.04);
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
.card-title {
display: flex;
align-items: center;
text {
margin-left: 12rpx;
font-size: 28rpx;
font-weight: 600;
color: #333;
}
}
.card-action {
display: flex;
align-items: center;
text {
margin-right: 8rpx;
font-size: 24rpx;
color: #999;
}
}
}
}
// 订单状态
.order-status {
display: flex;
padding: 30rpx 0 20rpx;
.status-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
.status-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
margin-bottom: 12rpx;
.status-badge {
position: absolute;
top: -10rpx;
right: -10rpx;
z-index: 2;
min-width: 32rpx;
height: 32rpx;
padding: 0 6rpx;
font-size: 20rpx;
line-height: 32rpx;
color: #fff;
text-align: center;
background-color: #ff4d4f;
border-radius: 16rpx;
}
}
.status-label {
font-size: 24rpx;
color: #666;
}
}
}
// 工具网格
.tools-grid {
display: flex;
flex-wrap: wrap;
padding: 20rpx 0 10rpx;
.tool-item {
display: flex;
flex-direction: column;
align-items: center;
width: 25%;
margin-bottom: 30rpx;
.tool-icon {
display: flex;
align-items: center;
justify-content: center;
width: 90rpx;
height: 90rpx;
margin-bottom: 12rpx;
background-color: rgba(var(--primary-color-rgb), 0.08);
border-radius: 18rpx;
transition: transform 0.2s;
&:active {
transform: scale(0.95);
}
}
.tool-label {
font-size: 24rpx;
color: #555;
}
}
}
// 服务列表
.services-list {
.service-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.service-left {
display: flex;
align-items: center;
.service-icon {
display: flex;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
background-color: rgba(var(--primary-color-rgb), 0.08);
border-radius: 16rpx;
}
.service-info {
margin-left: 20rpx;
.service-name {
margin-bottom: 6rpx;
font-size: 28rpx;
font-weight: 500;
color: #333;
}
.service-desc {
font-size: 24rpx;
color: #999;
}
}
}
}
}
// 退出登录按钮
.logout-btn-container {
margin: 60rpx 30rpx;
}
</style>
<style>
/* 使用全局样式解决微信小程序组件样式问题 */
.logout-btn-unocss {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
height: 80rpx !important;
font-size: 32rpx !important;
font-weight: bold !important;
color: #fff !important;
background-color: var(--primary-color) !important;
border: 2rpx solid var(--primary-color) !important;
border-radius: 40rpx !important;
box-shadow: 0 4rpx 12rpx rgba(var(--primary-color-rgb), 0.3) !important;
}
.btn-login {
width: 160rpx !important;
height: 60rpx !important;
font-size: 26rpx !important;
color: var(--primary-color) !important;
background-color: #fff !important;
border: none !important;
border-radius: 30rpx !important;
}
</style>

View File

@ -0,0 +1,220 @@
<template>
<view class="profile">
<view v-if="userProfile" class="profile-card">
<wd-cell-group border>
<wd-cell class="avatar-cell" title="头像" center is-link>
<view class="avatar">
<view v-if="!userProfile.avatar" class="img" @click="avatarUpload">
<wd-icon name="fill-camera" custom-class="img-icon" />
</view>
<wd-img
v-if="userProfile.avatar"
round
width="80px"
height="80px"
:src="userProfile.avatar"
mode="aspectFit"
custom-class="profile-img"
@click="avatarUpload"
/>
</view>
</wd-cell>
<wd-cell title="昵称" :value="userProfile.nickname" is-link @click="handleOpenDialog()" />
<wd-cell
title="性别"
:value="userProfile.gender === 1 ? '男' : userProfile.gender === 2 ? '女' : '未知'"
is-link
@click="handleOpenDialog()"
/>
<wd-cell title="用户名" :value="userProfile.username" />
<wd-cell title="部门" :value="userProfile.deptName" />
<wd-cell title="角色" :value="userProfile.roleNames" />
<wd-cell title="创建日期" :value="userProfile.createTime" />
</wd-cell-group>
</view>
<!--头像裁剪-->
<wd-img-cropper v-model="avatarShow" :img-src="originalSrc" @confirm="handleAvatarConfirm" />
<!--用户信息编辑弹出框-->
<wd-popup v-model="dialog.visible" position="bottom">
<wd-form ref="userProfileFormRef" :model="userProfileForm" custom-class="edit-form">
<wd-cell-group border>
<wd-input
v-model="userProfileForm.nickname"
label="昵称"
label-width="160rpx"
placeholder="请输入昵称"
prop="nickname"
:rules="rules.nickname"
/>
<wd-cell title="性别" title-width="160rpx" center prop="gender" :rules="rules.gender">
<wd-radio-group v-model="userProfileForm.gender" shape="button" class="ef-radio-group">
<wd-radio :value="1"></wd-radio>
<wd-radio :value="2"></wd-radio>
</wd-radio-group>
</wd-cell>
</wd-cell-group>
<view class="footer">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import UserAPI, { type UserProfileVO, UserProfileForm } from "@/api/system/user";
import FileAPI, { type FileInfo } from "@/api/file";
import { checkLogin } from "@/utils/auth";
const originalSrc = ref<string>(""); //选取的原图路径
const avatarShow = ref<boolean>(false); //显示头像裁剪
const userProfile = ref<UserProfileVO>(); //用户信息
/** 加载用户信息 */
const loadUserProfile = async () => {
userProfile.value = await UserAPI.getProfile();
};
// 头像选择
function avatarUpload() {
uni.chooseImage({
count: 1,
success: (res) => {
originalSrc.value = res.tempFilePaths[0];
avatarShow.value = true;
},
});
}
// 头像裁剪完成
function handleAvatarConfirm(event: any) {
const { tempFilePath } = event;
FileAPI.upload(tempFilePath).then((fileInfo: FileInfo) => {
const avatarForm: UserProfileForm = {
avatar: fileInfo.url,
};
// 头像路径保存至后端
UserAPI.updateProfile(avatarForm).then(() => {
uni.showToast({ title: "头像上传成功", icon: "none" });
loadUserProfile();
});
});
}
// 本页面中所有的校验规则
const rules = reactive({
nickname: [{ required: true, message: "请填写昵称" }],
gender: [{ required: true, message: "请选择性别" }],
});
const dialog = reactive({
visible: false,
});
const userProfileForm = reactive<UserProfileForm>({});
const userProfileFormRef = ref();
/**
* 打开弹窗
* @param type 弹窗类型 ACCOUNT: 账号资料 PASSWORD: 修改密码 MOBILE: 绑定手机 EMAIL: 绑定邮箱
*/
const handleOpenDialog = () => {
dialog.visible = true;
// 初始化表单数据
userProfileForm.nickname = userProfile.value?.nickname;
userProfileForm.gender = userProfile.value?.gender;
};
// 提交表单
function handleSubmit() {
userProfileFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.updateProfile(userProfileForm).then(() => {
uni.showToast({ title: "账号资料修改成功", icon: "none" });
dialog.visible = false;
loadUserProfile();
});
}
});
}
// 检查登录状态
onLoad(() => {
if (!checkLogin()) return;
// #ifdef H5
document.addEventListener("touchstart", touchstartListener, { passive: false });
document.addEventListener("touchmove", touchmoveListener, { passive: false });
// #endif
loadUserProfile();
});
onMounted(() => {
// 在onMounted中不再重复检查登录状态和加载用户信息
// 如果需要检查登录状态和加载用户信息请使用onLoad中的逻辑
});
// 页面销毁前移除事件监听
onBeforeUnmount(() => {
// #ifdef H5
document.removeEventListener("touchstart", touchstartListener);
document.removeEventListener("touchmove", touchmoveListener);
// #endif
});
// 禁用浏览器双指缩放,使头像裁剪时双指缩放能够起作用
function touchstartListener(event: TouchEvent) {
if (event.touches.length > 1) {
event.preventDefault();
}
}
// 禁用浏览器下拉刷新,使头像裁剪时能够移动图片
function touchmoveListener(event: TouchEvent) {
event.preventDefault();
}
</script>
<style lang="scss" scoped>
.profile {
.profile-card {
padding: 10rpx;
margin-bottom: 24rpx;
line-height: 1.1;
background-color: rgb(255, 255, 255);
border-radius: 16px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.05);
.avatar-cell {
:deep(.wd-cell__body) {
align-items: center;
}
.avatar {
display: flex;
align-items: center;
justify-content: right;
.img {
position: relative;
width: 80px;
height: 80px;
background-color: rgba(0, 0, 0, 0.04);
border-radius: 50%;
.img-icon {
position: absolute;
top: 50%;
left: 50%;
color: #fff;
}
}
}
}
}
.edit-form {
padding-top: 40rpx;
.ef-radio-group {
line-height: 1;
text-align: left;
}
.footer {
padding: 24rpx;
}
}
}
</style>

View File

@ -0,0 +1,343 @@
<template>
<view class="profile">
<view class="profile-card">
<wd-cell-group border>
<wd-cell
title="账户密码"
label="定期修改密码有助于保护账户安全"
value="修改"
is-link
@click="handleOpenDialog(DialogType.PASSWORD)"
/>
<wd-cell
title="绑定手机"
:value="userProfile?.mobile"
is-link
@click="handleOpenDialog(DialogType.MOBILE)"
/>
<wd-cell
title="绑定邮箱"
:value="userProfile?.email ? userProfile.email : '未绑定邮箱'"
is-link
@click="handleOpenDialog(DialogType.EMAIL)"
/>
</wd-cell-group>
</view>
<!--用户信息编辑弹出框-->
<wd-popup v-model="dialog.visible" position="bottom">
<wd-form
v-if="dialog.type === DialogType.PASSWORD"
ref="passwordChangeFormRef"
:model="passwordChangeForm"
custom-class="edit-form"
>
<wd-cell-group border>
<wd-input
v-model="passwordChangeForm.oldPassword"
label="原密码"
label-width="160rpx"
show-password
clearable
placeholder="请输入原密码"
prop="oldPassword"
:rules="rules.oldPassword"
/>
<wd-input
v-model="passwordChangeForm.newPassword"
label="新密码"
label-width="160rpx"
show-password
clearable
placeholder="请输入新密码"
prop="newPassword"
:rules="rules.newPassword"
/>
<wd-input
v-model="passwordChangeForm.confirmPassword"
label="确认密码"
label-width="160rpx"
show-password
clearable
placeholder="请确认新密码"
prop="confirmPassword"
:rules="rules.confirmPassword"
/>
</wd-cell-group>
<view class="footer">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
<wd-form
v-if="dialog.type === DialogType.MOBILE"
ref="mobileBindingFormRef"
:model="mobileBindingForm"
custom-class="edit-form"
>
<wd-cell-group border>
<wd-input
v-model="mobileBindingForm.mobile"
label="手机号码"
label-width="160rpx"
clearable
placeholder="请输入手机号码"
prop="mobile"
:rules="rules.mobile"
/>
<wd-input
v-model="mobileBindingForm.code"
label="验证码"
label-width="160rpx"
clearable
placeholder="请输入验证码"
prop="code"
:rules="rules.code"
>
<template #suffix>
<wd-button
plain
:disabled="mobileCountdown > 0"
@click="handleSendVerificationCode('MOBILE')"
>
{{ mobileCountdown > 0 ? `${mobileCountdown}s后重新发送` : "发送验证码" }}
</wd-button>
</template>
</wd-input>
</wd-cell-group>
<view class="footer">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
<wd-form
v-if="dialog.type === DialogType.EMAIL"
ref="emailBindingFormRef"
:model="emailBindingForm"
custom-class="edit-form"
>
<wd-cell-group border>
<wd-input
v-model="emailBindingForm.email"
label="邮箱"
label-width="160rpx"
clearable
placeholder="请输入邮箱"
prop="email"
:rules="rules.email"
/>
<wd-input
v-model="emailBindingForm.code"
label="验证码"
label-width="160rpx"
clearable
placeholder="请输入验证码"
prop="code"
:rules="rules.code"
>
<template #suffix>
<wd-button
plain
:disabled="emailCountdown > 0"
@click="handleSendVerificationCode('EMAIL')"
>
{{ emailCountdown > 0 ? `${emailCountdown}s后重新发送` : "发送验证码" }}
</wd-button>
</template>
</wd-input>
</wd-cell-group>
<view class="footer">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import UserAPI, {
PasswordChangeForm,
MobileBindingForm,
EmailBindingForm,
UserProfileVO,
} from "@/api/system/user";
const validatorConfirmPassword = (value: string) => {
if (!value) {
return Promise.reject("请确认密码");
} else {
if (value !== passwordChangeForm.newPassword) {
return Promise.reject("两次输入的密码不一致");
} else {
return Promise.resolve();
}
}
};
// 本页面中所有的校验规则
const rules = reactive({
oldPassword: [{ required: true, message: "请填写原密码" }],
newPassword: [{ required: true, message: "请填写新密码" }],
confirmPassword: [{ required: true, message: "请确认密码", validator: validatorConfirmPassword }],
mobile: [{ required: true, pattern: /^1[3-9]\d{9}$/, message: "请填写正确的手机号码" }],
code: [{ required: true, message: "请填写验证码" }],
email: [
{
required: true,
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
message: "请填写正确的邮箱地址",
},
],
});
enum DialogType {
PASSWORD = "password",
MOBILE = "mobile",
EMAIL = "email",
}
const dialog = reactive({
visible: false,
type: "" as DialogType, // 修改账号资料,修改密码、绑定手机、绑定邮箱
});
const userProfile = ref<UserProfileVO>(); //用户信息
const passwordChangeForm = reactive<PasswordChangeForm>({});
const mobileBindingForm = reactive<MobileBindingForm>({});
const emailBindingForm = reactive<EmailBindingForm>({});
const passwordChangeFormRef = ref();
const mobileBindingFormRef = ref();
const emailBindingFormRef = ref();
const mobileCountdown = ref(0);
const mobileTimer = ref<ReturnType<typeof setInterval> | null>(null);
const emailCountdown = ref(0);
const emailTimer = ref<ReturnType<typeof setTimeout> | null>(null);
/** 加载用户信息 */
const loadUserProfile = async () => {
userProfile.value = await UserAPI.getProfile();
};
/**
* 打开弹窗
* @param type 弹窗类型 ACCOUNT: 账号资料 PASSWORD: 修改密码 MOBILE: 绑定手机 EMAIL: 绑定邮箱
*/
const handleOpenDialog = (type: DialogType) => {
dialog.type = type;
dialog.visible = true;
switch (type) {
case DialogType.PASSWORD:
passwordChangeForm.oldPassword = "";
passwordChangeForm.newPassword = "";
passwordChangeForm.confirmPassword = "";
break;
case DialogType.MOBILE:
mobileBindingForm.mobile = "";
mobileBindingForm.code = "";
break;
case DialogType.EMAIL:
emailBindingForm.email = "";
emailBindingForm.code = "";
break;
}
};
/**
* 发送验证码
*
* @param contactType 联系方式类型 MOBILE: 手机号码 EMAIL: 邮箱
*/
const handleSendVerificationCode = async (contactType: string) => {
if (contactType === "MOBILE") {
mobileBindingFormRef.value.validate("mobile").then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.sendVerificationCode(mobileBindingForm.mobile!, "MOBILE").then(() => {
uni.showToast({ title: "验证码已发送", icon: "none" });
mobileCountdown.value = 60;
mobileTimer.value = setInterval(() => {
if (mobileCountdown.value > 0) {
mobileCountdown.value -= 1;
} else {
clearInterval(mobileTimer.value!);
}
}, 1000);
});
}
});
} else if (contactType === "EMAIL") {
emailBindingFormRef.value.validate("email").then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.sendVerificationCode(emailBindingForm.email!, "EMAIL").then(() => {
uni.showToast({ title: "验证码已发送", icon: "none" });
emailCountdown.value = 60;
emailTimer.value = setInterval(() => {
if (emailCountdown.value > 0) {
emailCountdown.value -= 1;
} else {
clearInterval(emailTimer.value!);
}
}, 1000);
});
}
});
}
};
// 提交表单
function handleSubmit() {
if (dialog.type === DialogType.PASSWORD) {
passwordChangeFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.changePassword(passwordChangeForm).then(() => {
uni.showToast({ title: "密码修改成功", icon: "none" });
dialog.visible = false;
});
}
});
} else if (dialog.type === DialogType.MOBILE) {
mobileBindingFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.bindMobile(mobileBindingForm).then(() => {
uni.showToast({ title: "手机号绑定成功", icon: "none" });
dialog.visible = false;
loadUserProfile();
});
}
});
} else if (dialog.type === DialogType.EMAIL) {
emailBindingFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.bindEmail(emailBindingForm).then(() => {
uni.showToast({ title: "邮箱绑定成功", icon: "none" });
dialog.visible = false;
loadUserProfile();
});
}
});
}
}
onMounted(() => {
loadUserProfile();
});
</script>
<style lang="scss" scoped>
.profile {
.profile-card {
padding: 10rpx;
margin-bottom: 24rpx;
line-height: 1.1;
background-color: rgb(255, 255, 255);
border-radius: 16px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.05);
}
.edit-form {
padding-top: 40rpx;
.ef-radio-group {
line-height: 1;
text-align: left;
}
.footer {
padding: 24rpx;
}
}
}
</style>

View File

@ -0,0 +1,98 @@
<template>
<view class="agreement-container">
<view class="agreement-header">
<text class="header-title">用户协议</text>
<text class="header-date">更新日期2024年3月15日</text>
</view>
<view class="agreement-content">
<view v-for="(section, index) in agreementContent" :key="index" class="section">
<text class="section-title">{{ section.title }}</text>
<text class="section-text">{{ section.content }}</text>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
const agreementContent = [
{
title: "1. 协议的范围",
content:
"本协议是您与我们之间关于使用本应用服务所订立的协议。您在使用本应用服务时,须完全接受本协议所有条款。",
},
{
title: "2. 服务内容",
content:
"本应用向您提供以下服务:网络状态检测、网络性能测试以及其他相关服务。我们将持续优化和更新服务内容,为您提供更好的使用体验。",
},
{
title: "3. 用户隐私",
content:
"我们重视用户的隐私保护,收集信息仅用于提供网络测试服务、改善用户体验和必要的系统维护。我们承诺对您的信息进行严格保密。",
},
{
title: "4. 用户行为规范",
content:
"您在使用本服务时必须遵守中华人民共和国相关法律法规。您不得利用本服务从事违法违规活动。如发现违规行为,我们有权终止服务。",
},
{
title: "5. 免责声明",
content:
"由于网络服务的特殊性,本应用不保证服务一定能满足用户的所有要求。对于因网络状态、通信线路等不可控因素导致的服务中断或其他缺陷,本应用不承担任何责任。",
},
{
title: "6. 协议修改",
content:
"我们保留随时修改本协议的权利。协议修改后,如果您继续使用本应用服务,即视为您已接受修改后的协议。我们建议您定期查看本协议以了解任何变更。",
},
];
</script>
<style lang="scss" scoped>
.agreement-container {
min-height: 100vh;
padding: 20px;
background-color: #fff;
}
.agreement-header {
margin-bottom: 30px;
text-align: center;
.header-title {
display: block;
margin-bottom: 10px;
font-size: 22px;
font-weight: 600;
color: #333;
}
.header-date {
font-size: 14px;
color: #999;
}
}
.agreement-content {
.section {
margin-bottom: 25px;
.section-title {
display: block;
margin-bottom: 12px;
font-size: 17px;
font-weight: 500;
color: #333;
}
.section-text {
display: block;
font-size: 15px;
line-height: 1.6;
color: #666;
text-align: justify;
}
}
}
</style>

View File

@ -0,0 +1,271 @@
<template>
<view class="settings-container">
<wd-cell-group>
<wd-cell title="个人资料" icon="user" is-link @click="navigateToProfile" />
<wd-cell title="账号和安全" icon="secured" is-link @click="navigateToAccount" />
<wd-cell title="主题设置" icon="setting1" is-link @click="navigateToTheme" />
<wd-cell title="用户协议" icon="user" is-link @click="navigateToUserAgreement" />
<wd-cell title="隐私政策" icon="folder" is-link @click="navigateToPrivacy" />
<wd-cell title="关于我们" icon="info" is-link @click="navigateToAbout" />
</wd-cell-group>
<wd-cell-group custom-style="margin-top:40rpx">
<wd-cell title="网络测试" icon="wifi" is-link @click="navigateToNetworkTest" />
<wd-cell
title="清空缓存"
icon="delete1"
:value="cacheSize"
clickable
@click="handleClearCache"
/>
</wd-cell-group>
<view v-if="isLogin" class="logout-section">
<wd-button class="logout-btn" @click="handleLogout">退出登录</wd-button>
</view>
<!-- 全屏 loading -->
<view v-if="clearing" class="loading-mask">
<view class="loading-content">
<view class="loading-icon" />
<text class="loading-text">正在清理...</text>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { useUserStore } from "@/store/modules/user";
import { checkLogin } from "@/utils/auth";
import { computed, ref } from "vue";
const userStore = useUserStore();
const isLogin = computed(() => !!userStore.userInfo);
// 个人资料
const navigateToProfile = () => {
if (checkLogin()) {
uni.navigateTo({
url: "/pages/mine/profile/index",
});
}
};
// 账号和安全
const navigateToAccount = () => {
if (checkLogin()) {
uni.navigateTo({
url: "/pages/mine/settings/account/index",
});
}
};
// 主题设置
const navigateToTheme = () => {
uni.navigateTo({
url: "/pages/mine/settings/theme/index",
});
};
// 用户协议
const navigateToUserAgreement = () => {
uni.navigateTo({
url: "/pages/mine/agreements/user-agreement",
});
};
// 隐私政策
const navigateToPrivacy = () => {
uni.navigateTo({
url: "/pages/mine/agreements/privacy-policy",
});
};
// 关于我们
const navigateToAbout = () => {
uni.navigateTo({
url: "/pages/mine/about/index",
});
};
// 网络测试
const navigateToNetworkTest = () => {
uni.navigateTo({ url: "/pages/mine/settings/network/index" });
};
// 是否正在清理
const clearing = ref(false);
// 缓存大小
const cacheSize = ref<any>("计算中...");
// 获取缓存大小
const getCacheSize = async () => {
try {
// #ifdef MP-WEIXIN
const res = await uni.getStorageInfo();
cacheSize.value = formatSize(res.currentSize);
// #endif
// #ifdef H5
cacheSize.value = formatSize(
Object.keys(localStorage).reduce((size, key) => size + localStorage[key].length, 0)
);
// #endif
if (!cacheSize.value) {
cacheSize.value = "0B";
}
} catch (error) {
console.error("获取缓存大小失败:", error);
cacheSize.value = "获取失败";
}
};
// 格式化存储大小
const formatSize = (size: number) => {
if (size < 1024) {
return size + "B";
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + "KB";
} else {
return (size / 1024 / 1024).toFixed(2) + "MB";
}
};
// 处理清除缓存
const handleClearCache = async () => {
if (cacheSize.value === "获取失败") {
uni.showToast({
title: "获取缓存信息失败,请稍后重试",
icon: "none",
duration: 2000,
});
return;
}
if (cacheSize.value === "0B") {
uni.showToast({
title: "暂无缓存需要清理",
icon: "none",
duration: 2000,
});
return;
}
if (clearing.value) {
return;
}
try {
clearing.value = true;
// 模拟清理过程
await new Promise((resolve) => setTimeout(resolve, 1500));
// 清除缓存
await uni.clearStorage();
// 更新缓存大小显示
await getCacheSize();
// 提示清理成功
uni.showToast({
title: "清理成功",
icon: "success",
});
} catch {
uni.showToast({
title: "清理失败",
icon: "error",
});
} finally {
clearing.value = false;
}
};
// 退出登录
const handleLogout = () => {
uni.showModal({
title: "提示",
content: "确定要退出登录吗?",
success: function (res) {
if (res.confirm) {
userStore.logout();
uni.showToast({
title: "已退出登录",
icon: "success",
});
}
},
});
};
// 检查登录状态
onLoad(() => {
if (!checkLogin()) return;
getCacheSize();
});
</script>
<style lang="scss" scoped>
.settings-container {
min-height: 100vh;
padding: 20px;
.loading-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.4);
.loading-content {
padding: 30rpx 40rpx;
text-align: center;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
.loading-icon {
width: 60rpx;
height: 60rpx;
margin: 0 auto 20rpx;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
}
}
.logout-section {
display: flex;
align-items: center;
justify-content: center;
padding: 0 20rpx;
margin-top: 60rpx;
}
.logout-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 90rpx;
font-size: 32rpx;
font-weight: 500;
color: #fff;
background-color: var(--primary-color);
border: none;
border-radius: 45rpx;
box-shadow: 0 4rpx 12rpx rgba(var(--primary-color-rgb), 0.3);
transition: opacity 0.2s;
&:active {
opacity: 0.85;
}
}
}
</style>

View File

@ -0,0 +1,326 @@
<template>
<view class="network-container">
<!-- 网络状态展示 -->
<view class="status-card">
<view class="status-header">
<text class="title">网络状态</text>
<text :class="['status-badge', networkType ? 'online' : 'offline']">
{{ networkType ? "在线" : "离线" }}
</text>
</view>
<view class="status-info">
<view class="info-item">
<text class="label">网络类型</text>
<text class="value">{{ networkType || "未知" }}</text>
</view>
<view class="info-item">
<text class="label">网络强度</text>
<text class="value">{{ signalStrength }}</text>
</view>
</view>
</view>
<!-- 网络测试 -->
<view class="test-card">
<view class="test-header">
<text class="title">网络测试</text>
<text class="subtitle">测试服务器连接情况</text>
</view>
<view class="test-content">
<view class="test-item">
<text class="label">延迟</text>
<text class="value">{{ pingResult.delay }}ms</text>
<text :class="['status', getPingStatus]">{{ pingResult.status }}</text>
</view>
<view v-if="testing" class="progress-bar">
<view class="progress" :style="{ width: `${progress}%` }" />
</view>
<button class="test-btn" :disabled="testing" @click="startTest">
{{ testing ? "测试中..." : "开始测试" }}
</button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import request from "@/utils/request";
interface PingResult {
delay: string | number;
status: string;
}
// 声明全局 wx 对象
declare const wx: any;
// 响应式状态
const networkType = ref("");
const signalStrength = ref("获取中...");
const testing = ref(false);
const progress = ref(0);
const pingResult = ref<PingResult>({
delay: "--",
status: "未测试",
});
const networkListener = ref<any>(null);
// 计算属性
const getPingStatus = computed(() => {
if (pingResult.value.delay === "--") return "";
// 将 delay 转换为数字进行比较
const delay = Number(pingResult.value.delay);
if (isNaN(delay)) return "";
if (delay < 100) return "good";
if (delay < 300) return "normal";
return "bad";
});
// 方法
const getNetworkType = async () => {
try {
const res = await uni.getNetworkType();
networkType.value = res.networkType;
// 微信小程序支持获取信号强度
// #ifdef MP-WEIXIN
if (wx?.getNetworkWeakness) {
const weaknessRes = await wx.getNetworkWeakness();
signalStrength.value = `${weaknessRes.weaknessLevel}%`;
} else {
signalStrength.value = "不支持";
}
// #endif
// H5环境
// #ifdef H5
signalStrength.value = (navigator as any).connection
? `${(navigator as any).connection.effectiveType || "未知"}`
: "不支持";
// #endif
} catch {
networkType.value = "获取失败";
signalStrength.value = "获取失败";
}
};
// 监听网络状态变化
const listenNetworkStatus = () => {
// #ifdef MP-WEIXIN
networkListener.value = wx?.onNetworkStatusChange((res: any) => {
networkType.value = res.networkType;
getNetworkType();
});
// #endif
// #ifdef H5
window.addEventListener("online", getNetworkType);
window.addEventListener("offline", () => {
networkType.value = "";
signalStrength.value = "离线";
});
// #endif
};
// 开始网络测试
const startTest = async () => {
if (testing.value) return;
testing.value = true;
progress.value = 0;
pingResult.value.delay = "--";
pingResult.value.status = "测试中";
const progressTimer = setInterval(() => {
if (progress.value < 90) {
progress.value += 10;
}
}, 200);
try {
const startTime = Date.now();
// #ifdef H5
await uni.request({
url: "/api/v1/auth/captcha",
timeout: 5000,
});
// #endif
// #ifndef H5
await request({
url: "/api/v1/auth/captcha",
timeout: 5000,
});
// #endif
const endTime = Date.now();
const delay = endTime - startTime;
pingResult.value.delay = delay;
pingResult.value.status = delay < 300 ? "正常" : "较慢";
} catch {
pingResult.value.delay = "--";
pingResult.value.status = "连接失败";
} finally {
clearInterval(progressTimer);
progress.value = 100;
setTimeout(() => {
testing.value = false;
progress.value = 0;
}, 500);
}
};
// 生命周期钩子
onMounted(() => {
getNetworkType();
listenNetworkStatus();
});
onBeforeUnmount(() => {
// #ifdef MP-WEIXIN
if (networkListener.value?.clear) {
networkListener.value.clear();
}
// #endif
// #ifdef H5
window.removeEventListener("online", getNetworkType);
window.removeEventListener("offline", getNetworkType);
// #endif
});
</script>
<style lang="scss" scoped>
.network-container {
min-height: 100vh;
padding: 20px;
background-color: #f5f5f5;
}
.status-card,
.test-card {
padding: 20px;
margin-bottom: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.status-header,
.test-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
.title {
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.status-badge {
padding: 4px 12px;
font-size: 14px;
border-radius: 12px;
&.online {
color: #4caf50;
background-color: #e8f5e9;
}
&.offline {
color: #f44336;
background-color: #ffebee;
}
}
.status-info,
.test-content {
.info-item,
.test-item {
display: flex;
align-items: center;
margin-bottom: 12px;
.label {
width: 100px;
font-size: 15px;
color: #666;
}
.value {
flex: 1;
font-size: 15px;
color: #333;
}
.status {
padding: 2px 8px;
font-size: 13px;
border-radius: 4px;
&.good {
color: #4caf50;
background-color: #e8f5e9;
}
&.normal {
color: #ff9800;
background-color: #fff3e0;
}
&.bad {
color: #f44336;
background-color: #ffebee;
}
}
}
}
.test-header {
.subtitle {
font-size: 14px;
color: #999;
}
}
.progress-bar {
height: 4px;
margin: 20px 0;
overflow: hidden;
background-color: #f5f5f5;
border-radius: 2px;
.progress {
height: 100%;
background-color: #409eff;
transition: width 0.2s ease-in-out;
}
}
.test-btn {
width: 100%;
height: 44px;
margin-top: 20px;
font-size: 16px;
line-height: 44px;
color: #fff;
text-align: center;
background-color: #409eff;
border: none;
border-radius: 22px;
&:active {
opacity: 0.9;
}
&[disabled] {
cursor: not-allowed;
background-color: #a0cfff;
}
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<view class="privacy-container">
<view class="privacy-header">
<text class="header-title">隐私政策</text>
<text class="header-date">更新日期2024年3月15日</text>
</view>
<view class="privacy-content">
<view v-for="(section, index) in privacyContent" :key="index" class="section">
<text class="section-title">{{ section.title }}</text>
<text class="section-text">{{ section.content }}</text>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
const privacyContent = ref([
{
title: "1. 信息收集",
content:
"我们可能收集您的基本信息(如设备信息、操作日志等)以提供更好的服务体验。我们承诺对这些信息进行严格保密,并只用于改善产品服务。",
},
{
title: "2. 信息使用",
content:
"收集的信息将用于:优化用户体验、提供客户支持、发送重要通知、保障账号安全等。未经您的同意,我们不会向第三方分享您的个人信息。",
},
{
title: "3. 信息安全",
content:
"我们采用业界标准的安全技术和程序来保护您的个人信息,防止未经授权的访问、使用或泄露。我们定期审查信息收集、存储和处理实践。",
},
{
title: "4. Cookie 使用",
content:
"我们使用 Cookie 和类似技术来提供、保护和改进我们的产品和服务。这些技术帮助我们了解用户行为,告诉我们哪些功能最受欢迎。",
},
{
title: "5. 第三方服务",
content:
"我们的应用可能包含第三方服务。这些第三方服务有自己的隐私政策,我们建议您查看这些政策。我们不对第三方的隐私实践负责。",
},
{
title: "6. 未成年人保护",
content:
"我们非常重视对未成年人个人信息的保护。若您是未成年人,请在监护人指导下使用我们的服务。如果您是监护人,当您对您所监护的未成年人的个人信息有疑问时,请联系我们。",
},
{
title: "7. 隐私政策更新",
content:
"我们可能会不时更新本隐私政策。当我们更新隐私政策时,我们会在本页面上发布更新后的版本并修改更新日期。建议您定期查看本页面。",
},
]);
</script>
<style lang="scss" scoped>
.privacy-container {
min-height: 100vh;
padding: 20px;
background-color: #fff;
}
.privacy-header {
margin-bottom: 30px;
text-align: center;
.header-title {
display: block;
margin-bottom: 10px;
font-size: 22px;
font-weight: 600;
color: #333;
}
.header-date {
font-size: 14px;
color: #999;
}
}
.privacy-content {
.section {
margin-bottom: 25px;
.section-title {
display: block;
margin-bottom: 12px;
font-size: 17px;
font-weight: 500;
color: #333;
}
.section-text {
display: block;
font-size: 15px;
line-height: 1.6;
color: #666;
text-align: justify;
}
}
}
</style>

View File

@ -0,0 +1,304 @@
<template>
<view class="theme-settings-container">
<view class="title">主题色设置</view>
<view class="color-palette">
<view class="subtitle">选择主题色</view>
<view class="color-grid">
<view
v-for="(color, index) in themeColors"
:key="index"
class="color-item"
:style="{ backgroundColor: color }"
@click="handleSelectColor(color)"
>
<wd-icon v-if="themeStore.primaryColor === color" name="check" color="#fff" size="16" />
</view>
</view>
</view>
<view class="custom-color">
<view class="subtitle">自定义主题色</view>
<view class="color-picker">
<view class="color-preview" :style="{ backgroundColor: customColor }"></view>
<input
v-model="customColor"
type="text"
placeholder="请输入十六进制颜色值,如 #165DFF"
class="color-input"
maxlength="7"
/>
</view>
<button class="apply-btn" @click="applyCustomColor">应用</button>
</view>
<view class="preview-section">
<view class="subtitle">预览效果</view>
<view class="preview-container">
<view class="preview-item">
<view class="preview-button" :style="{ backgroundColor: themeStore.primaryColor }">
按钮
</view>
</view>
<view class="preview-item">
<view class="preview-text" :style="{ color: themeStore.primaryColor }">文本颜色</view>
</view>
<view class="preview-item">
<view class="preview-border" :style="{ borderColor: themeStore.primaryColor }">边框</view>
</view>
</view>
</view>
<button class="reset-btn" @click="resetTheme">恢复默认主题色</button>
</view>
</template>
<script lang="ts" setup>
import { onLoad, onShow } from "@dcloudio/uni-app";
import { useThemeStore } from "@/store";
import { applyThemeOnPageShow } from "@/utils/theme";
const themeStore = useThemeStore();
// 预设主题色
const themeColors = [
"#165DFF", // Arco蓝色
"#0FC6C2", // 青绿色
"#722ED1", // 紫色
"#F5222D", // 红色
"#FA8C16", // 橙色
"#FADB14", // 黄色
"#52C41A", // 绿色
"#EB2F96", // 粉色
];
// 自定义颜色
const customColor = ref("#165DFF");
// 选择预设颜色
const handleSelectColor = (color: string) => {
themeStore.setPrimaryColor(color);
customColor.value = color;
// 保存设置
saveThemeSettings();
// 提示
uni.showToast({
title: "主题色已更新",
icon: "success",
});
};
// 应用自定义颜色
const applyCustomColor = () => {
// 验证颜色格式
const colorRegex = /^#([0-9A-F]{6})$/i;
if (!colorRegex.test(customColor.value)) {
uni.showToast({
title: "请输入有效的颜色值",
icon: "none",
});
return;
}
themeStore.setPrimaryColor(customColor.value);
// 保存设置
saveThemeSettings();
// 提示
uni.showToast({
title: "自定义主题色已应用",
icon: "success",
});
};
// 重置为默认主题色
const resetTheme = () => {
const defaultColor = "#165DFF"; // Arco蓝色
themeStore.setPrimaryColor(defaultColor);
customColor.value = defaultColor;
// 保存设置
saveThemeSettings();
// 提示
uni.showToast({
title: "已恢复默认主题色",
icon: "success",
});
};
// 保存主题设置
const saveThemeSettings = () => {
// 主题store已经处理了持久化这里不需要额外操作
};
onLoad(() => {
// 初始化自定义颜色输入框
customColor.value = themeStore.primaryColor;
});
// 页面显示时重新应用主题(解决小程序环境问题)
onShow(() => {
// 在小程序环境中,页面显示时重新应用主题
if (typeof document === "undefined") {
applyThemeOnPageShow(themeStore.primaryColor);
// 为了确保样式应用,我们可以在页面中设置内联样式
// 这样可以绕过小程序的CSS变量限制
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage) {
console.log("当前页面重新应用主题色");
}
}
});
</script>
<style lang="scss" scoped>
.theme-settings-container {
padding: 30rpx;
.title {
margin-bottom: 30rpx;
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.subtitle {
margin-bottom: 20rpx;
font-size: 30rpx;
font-weight: 500;
color: #333;
}
.color-palette {
padding: 30rpx;
margin-bottom: 40rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.color-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
.color-item {
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
border-radius: 12rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
&:active {
transform: scale(0.95);
}
}
}
}
.custom-color {
padding: 30rpx;
margin-bottom: 40rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.color-picker {
display: flex;
align-items: center;
margin-bottom: 30rpx;
.color-preview {
width: 60rpx;
height: 60rpx;
margin-right: 20rpx;
border-radius: 8rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1);
}
.color-input {
flex: 1;
height: 80rpx;
padding: 0 20rpx;
font-size: 28rpx;
border: 1px solid #e0e0e0;
border-radius: 8rpx;
}
}
.apply-btn {
width: 100%;
height: 80rpx;
font-size: 28rpx;
line-height: 80rpx;
color: #fff;
background-color: var(--primary-color);
border: none;
border-radius: 8rpx;
}
}
.preview-section {
padding: 30rpx;
margin-bottom: 40rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.preview-container {
display: flex;
flex-direction: column;
gap: 20rpx;
.preview-item {
display: flex;
align-items: center;
justify-content: center;
}
.preview-button {
width: 100%;
height: 80rpx;
line-height: 80rpx;
color: #fff;
text-align: center;
border-radius: 8rpx;
}
.preview-text {
font-size: 32rpx;
font-weight: 500;
}
.preview-border {
width: 100%;
height: 80rpx;
line-height: 80rpx;
text-align: center;
background-color: #f5f5f5;
border: 2px solid;
border-radius: 8rpx;
}
}
}
.reset-btn {
width: 100%;
height: 80rpx;
font-size: 28rpx;
line-height: 80rpx;
color: #333;
background-color: #f5f5f5;
border: none;
border-radius: 8rpx;
}
}
</style>

273
src/pages/todo/index.vue Normal file
View File

@ -0,0 +1,273 @@
<template>
<view class="todo-container">
<view class="page-header">
<text class="page-title">待办事项</text>
<wd-button size="small" type="primary" icon="add" @click="showAddTodoPopup = true">
新建待办
</wd-button>
</view>
<view class="filter-tabs">
<wd-tabs v-model="activeTab" sticky>
<wd-tab title="全部" name="all" />
<wd-tab title="未完成" name="active" />
<wd-tab title="已完成" name="completed" />
</wd-tabs>
</view>
<TodoList :todos="filteredTodos" @update="handleUpdateTodo" @delete="handleDeleteTodo" />
<!-- 新增待办弹窗 -->
<wd-popup
v-model="showAddTodoPopup"
position="bottom"
close-on-click-modal
:style="{ height: '65%' }"
>
<view class="popup-header">
<text class="popup-title">新建待办</text>
<wd-icon name="close" @click="showAddTodoPopup = false" />
</view>
<view class="popup-form">
<wd-input
v-model="newTodo.title"
placeholder="请输入待办标题"
:rules="[{ required: true, message: '请输入标题' }]"
/>
<wd-textarea
v-model="newTodo.description"
placeholder="请输入详细描述"
rows="3"
autosize
class="mt-20"
/>
<wd-cell title="截止日期" is-link @click="showDatePicker = true">
<text v-if="newTodo.deadline">{{ formatDate(newTodo.deadline) }}</text>
<text v-else class="text-placeholder">请选择</text>
</wd-cell>
<wd-cell title="优先级">
<wd-radio-group v-model="newTodo.priority" shape="button">
<wd-radio value="low"></wd-radio>
<wd-radio value="medium"></wd-radio>
<wd-radio value="high"></wd-radio>
</wd-radio-group>
</wd-cell>
<view class="form-actions">
<wd-button
block
type="primary"
:loading="loading"
:disabled="!newTodo.title"
@click="handleAddTodo"
>
保存
</wd-button>
</view>
</view>
</wd-popup>
<!-- 日期选择器 -->
<wd-datetime-picker
v-model="showDatePicker"
v-model:value="newTodo.deadline"
label="截止日期"
type="date"
confirm-button-text="确认"
cancel-button-text="取消"
title="选择截止日期"
/>
<wd-toast />
</view>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
import { useToast } from "wot-design-uni";
import { Todo } from "@/types/todo";
import TodoList from "@/components/todo/TodoList.vue";
const toast = useToast();
const loading = ref(false);
const showAddTodoPopup = ref(false);
const showDatePicker = ref(false);
const activeTab = ref("all");
// 待办列表
const todos = ref<Todo[]>([
{
id: "1",
title: "完成个人资料填写",
description: "包括上传头像、填写基本信息等",
completed: false,
priority: "high",
createdAt: new Date().toISOString(),
},
{
id: "2",
title: "阅读使用指南",
description: "熟悉系统功能和操作流程",
completed: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
// 新增待办表单
const newTodo = ref<Partial<Todo>>({
title: "",
description: "",
priority: "medium",
deadline: "",
});
// 根据标签筛选待办
const filteredTodos = computed(() => {
switch (activeTab.value) {
case "active":
return todos.value.filter((todo) => !todo.completed);
case "completed":
return todos.value.filter((todo) => todo.completed);
default:
return todos.value;
}
});
// 格式化日期
const formatDate = (date: string) => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
// 生成唯一ID
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
};
// 添加待办
const handleAddTodo = () => {
if (!newTodo.value.title) {
toast.error("请输入待办标题");
return;
}
loading.value = true;
// 模拟API调用
setTimeout(() => {
const now = new Date().toISOString();
const todo: Todo = {
id: generateId(),
title: newTodo.value.title!,
description: newTodo.value.description,
completed: false,
deadline: newTodo.value.deadline,
priority: newTodo.value.priority as "low" | "medium" | "high",
createdAt: now,
};
todos.value.unshift(todo);
resetForm();
loading.value = false;
showAddTodoPopup.value = false;
toast.success("待办创建成功");
}, 500);
};
// 更新待办
const handleUpdateTodo = (todo: Todo) => {
const index = todos.value.findIndex((item) => item.id === todo.id);
if (index !== -1) {
todos.value[index] = {
...todo,
updatedAt: new Date().toISOString(),
};
toast.success(todo.completed ? "已完成" : "已取消完成");
}
};
// 删除待办
const handleDeleteTodo = (id: string) => {
uni.showModal({
title: "提示",
content: "确认删除该待办事项?",
success: (res) => {
if (res.confirm) {
todos.value = todos.value.filter((todo) => todo.id !== id);
toast.success("删除成功");
}
},
});
};
// 重置表单
const resetForm = () => {
newTodo.value = {
title: "",
description: "",
priority: "medium",
deadline: "",
};
};
</script>
<style lang="scss" scoped>
.todo-container {
min-height: 100vh;
padding: 30rpx;
background-color: #f5f7fa;
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.page-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
}
.filter-tabs {
margin-bottom: 20rpx;
overflow: hidden;
background-color: #fff;
border-radius: 12rpx;
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #eee;
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
}
.popup-form {
padding: 30rpx;
.form-actions {
margin-top: 40rpx;
}
}
.text-placeholder {
color: #999;
}
.mt-20 {
margin-top: 20rpx;
}
}
</style>

View File

@ -0,0 +1,324 @@
<template>
<view class="work">
<!-- 筛选 -->
<wd-drop-menu close-on-click-modal class="mb-20rpx mr-20rpx ml-20rpx">
<wd-drop-menu-item ref="dropMenu" title="筛选" icon="filter" icon-size="18px">
<view>
<wd-input
v-model="queryParams.keywords"
label="关键字"
type="text"
clearable
placeholder="请输入关键字"
/>
<view class="flex flex-row items-center mb-20rpx">
<wd-button class="mt-20rpx mb-20rpx" size="medium" @click="handleQuery()">
查询
</wd-button>
<wd-button size="medium" type="info" @click="handleReset">重置</wd-button>
</view>
</view>
</wd-drop-menu-item>
</wd-drop-menu>
<view class="data-container">
<!-- 列表内容 -->
<view v-for="(item, index) in pageData" :key="index" class="mb-20rpx">
<wd-card>
<template #title>
<view class="flex items-center justify-between">
<view class="flex-1 text-truncate">{{ item.configName }}</view>
</view>
</template>
<wd-cell-group>
<wd-cell title="配置键" :value="item.configKey" />
<wd-cell title="配置值" :value="item.configValue" />
<wd-cell title="备注" :value="item.remark" />
</wd-cell-group>
<template #footer>
<wd-button size="small" plain type="primary" @click="handleAction(item)">
操作
</wd-button>
</template>
</wd-card>
</view>
</view>
<!-- 加载更多 -->
<wd-loadmore :state="state" @reload="handleQuery" />
<!-- 底部按钮 -->
<view class="fixed bottom-0 w-full flex justify-around items-center p-20rpx bg-#fff">
<wd-button size="medium" type="primary" @click="handleOpenDialog">添加</wd-button>
<wd-button size="medium" type="success" @click="refreshCache">刷新缓存</wd-button>
</view>
<wd-popup v-model="showEditPopup" position="bottom">
<view class="p-20rpx">
<wd-form ref="formRef" :model="form">
<wd-input
v-model="form.configName"
label="配置名称"
type="text"
placeholder="请输入配置名称"
:rules="[{ required: true, message: '请填写配置名称' }]"
/>
<wd-input
v-model="form.configKey"
label="配置键名"
type="text"
placeholder="请输入配置键名"
:rules="[{ required: true, message: '请填写配置键' }]"
/>
<wd-input
v-model="form.configValue"
label="配置键值"
type="text"
placeholder="请输入配置键值"
:rules="[{ required: true, message: '请填写配置值' }]"
/>
<wd-textarea
v-model="form.remark"
prop="remark"
label="配置描述"
label-align="right"
clearable
:maxlength="100"
show-word-limit
label-width="100px"
placeholder="请输入配置描述"
/>
</wd-form>
<view class="flex justify-around mt-20rpx">
<wd-button @click="showEditPopup = false">取消</wd-button>
<wd-button type="primary" native-type="submit" :loading="loading" @click="submitForm">
确定
</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>
<script lang="ts" setup>
import ConfigAPI, { ConfigPageVO, ConfigForm, ConfigPageQuery } from "@/api/system/config";
import { DropMenuItemExpose } from "wot-design-uni/components/wd-drop-menu-item/types";
import { LoadMoreState } from "wot-design-uni/components/wd-loadmore/types";
import { FormInstance } from "wot-design-uni/components/wd-form/types";
import { debounce } from "@/utils";
const state = ref<LoadMoreState>("loading"); // 加载状态 loading, finished:, error
const total = ref(0);
const queryParams = reactive<ConfigPageQuery>({
pageNum: 1,
pageSize: 10,
keywords: "",
});
// 系统配置表格数据
const pageData = ref<ConfigPageVO[]>([]);
const formRef = ref<FormInstance>();
const loading = ref(false);
/**
* 搜索栏
*/
const dropMenu = ref<DropMenuItemExpose>();
/**
* 搜索
*/
function handleQuery() {
pageData.value = [];
dropMenu.value?.close();
queryParams.pageNum = 1;
loadmore();
}
const showEditPopup = ref(false);
const form = ref<ConfigForm>({});
// 修改编辑函数
function handleEdit(id: number) {
ConfigAPI.getFormData(id).then((data) => {
Object.assign(form.value, data);
// 显示弹窗
showEditPopup.value = true;
});
}
// 提交表单
async function submitForm() {
loading.value = true;
try {
if (formRef.value) {
formRef.value!.validate().then(async ({ valid }) => {
if (valid) {
if (form.value.id) {
await ConfigAPI.update(form.value.id, form.value);
uni.showToast({ title: "更新成功", icon: "success" });
} else {
await ConfigAPI.add(form.value);
uni.showToast({ title: "添加成功", icon: "success" });
}
showEditPopup.value = false;
handleQuery(); // 刷新列表
} else {
uni.showToast({ title: "请检查表单", icon: "error" });
loading.value = false;
}
});
}
} catch {
uni.showToast({ title: "操作失败", icon: "error" });
loading.value = false;
} finally {
loading.value = false;
}
}
/**
* 重置搜索条件
*/
function handleReset() {
queryParams.pageNum = 1;
queryParams.keywords = "";
pageData.value = [];
dropMenu.value?.close();
handleQuery();
}
/**
* 触底事件
*/
onReachBottom(() => {
queryParams.pageNum++;
handleQuery();
});
/**
* 加载更多
*/
function loadmore() {
state.value = "loading";
ConfigAPI.getPage(queryParams)
.then((data) => {
if (queryParams.pageNum === 1) {
pageData.value = data.list;
} else {
pageData.value.push(...data.list);
}
total.value = data.total;
})
.finally(() => {
state.value = "finished";
});
}
/**
* 添加
*/
function handleOpenDialog() {
form.value.id = undefined;
form.value.configName = "";
form.value.configKey = "";
form.value.configValue = "";
form.value.remark = "";
showEditPopup.value = true;
}
/**
* 刷新缓存
*/
const refreshCache = debounce(() => {
ConfigAPI.refreshCache().then(() => {
uni.showToast({
title: "刷新缓存成功",
icon: "success",
duration: 1000, // 显示时间,单位为毫秒,设置为 0 则不会自动消失
});
});
}, 1000);
/**
* 删除
*/
function handleDelete(item: ConfigPageVO) {
uni.showModal({
title: "提示",
content: "确定要删除该配置吗?",
success: async (res) => {
if (res.confirm) {
if (item.id) {
ConfigAPI.deleteById(item.id).then(() => {
uni.showToast({ title: "删除成功", icon: "success" });
});
}
}
},
});
}
/**
* 操作
*/
function handleAction(item: ConfigPageVO) {
const actions = ["编辑", "删除"];
uni.showActionSheet({
itemList: actions,
success: ({ tapIndex }) => {
switch (actions[tapIndex]) {
case "编辑":
handleEdit(item.id || 0);
break;
case "删除":
handleDelete(item);
break;
}
},
});
}
onMounted(() => {
handleQuery();
});
/**
* 页面返回回来也要重新加载数据
*/
onLoad(() => {
queryParams.pageNum = 1;
handleQuery();
});
</script>
<style lang="scss" scoped>
.data-container {
:deep(.wd-cell__wrapper) {
padding: 4rpx 0;
}
:deep(.wd-cell) {
padding-right: 10rpx;
background: #f8f8f8;
}
:deep(.wd-fab__trigger) {
width: 80rpx !important;
height: 80rpx !important;
}
:deep(.wd-cell__right) {
flex: 2;
}
.filter-container {
padding: 10rpx;
background: #fff;
}
.data-container {
margin-top: 20rpx;
}
}
</style>

81
src/pages/work/index.vue Normal file
View File

@ -0,0 +1,81 @@
<template>
<view class="work">
<template v-for="(item, index) in gridList" :key="index">
<wd-card :title="item.title">
<wd-grid clickable :column="4">
<wd-grid-item
v-for="(child, index) in item.children"
:key="index"
:v-has-perm="child.prem"
use-slot
link-type="navigateTo"
:url="child.url"
>
<view class="p-2">
<image class="w-72rpx h-72rpx rounded-8rpx" :src="child.icon" />
</view>
<view class="text">{{ child.title }}</view>
</wd-grid-item>
</wd-grid>
</wd-card>
</template>
</view>
</template>
<script lang="ts" setup>
const gridList = reactive([
{
title: "系统管理",
children: [
{
icon: "/static/icons/user.png",
title: "用户管理",
url: "/pages/work/user/index",
prem: "sys:user:query",
},
{
icon: "/static/icons/role.png",
title: "角色管理",
url: "/pages/work/role/index",
prem: "sys:role:query",
},
{
icon: "/static/icons/notice.png",
title: "通知公告",
url: "/pages/work/notice/index",
prem: "sys:notice:query",
},
{
icon: "/static/icons/setting.png",
title: "系统配置",
url: "/pages/work/config/index",
prem: "sys:config:query",
},
],
},
{
title: "系统监控",
children: [
{
icon: "/static/icons/log.png",
title: "系统日志",
url: "/pages/work/log/index",
prem: "sys:log:query",
},
],
},
]);
</script>
<style lang="scss" scoped>
/* stylelint-disable selector-type-no-unknown */
page {
background: #f8f8f8;
}
/* stylelint-enable selector-type-no-unknown */
.work {
padding: 40rpx 0;
}
</style>

View File

@ -0,0 +1,174 @@
<template>
<view class="log">
<!-- 筛选 -->
<wd-drop-menu close-on-click-modal class="mb-24rpx">
<wd-drop-menu-item ref="filterDropMenu" title="筛选" icon="filter" icon-size="18px">
<view>
<wd-input
v-model="queryParams.keywords"
label="关键字"
type="text"
placeholder="请输入关键字"
/>
<cu-date-query v-model="queryParams.createTime" :label="'日期选择'" />
<view class="flex-between py-2">
<wd-button class="w-20%" type="info" @click="handleResetQuery">重置</wd-button>
<wd-button class="w-70%" @click="handleQuery">查询</wd-button>
</view>
</view>
</wd-drop-menu-item>
</wd-drop-menu>
<!-- 卡片列表 -->
<wd-card v-for="item in pageData" :key="item.id" class="card-list">
<template #title>
{{ item.operator }}
</template>
<wd-cell-group>
<wd-cell title="模块" :value="item.module" />
<wd-cell title="内容" :value="item.content" />
<wd-cell title="IP" :value="item.ip" />
<wd-cell title="地区" :value="item.region" />
</wd-cell-group>
<template #footer>
<view class="flex-between">
<view class="text-left">
<wd-text text="创建时间:" size="small" class="font-bold" />
<wd-text :text="item.createTime" size="small" />
</view>
<view class="text-right">
<wd-button type="primary" size="small" plain @click="handleViewDetail(item)">
查看详情
</wd-button>
</view>
</view>
</template>
</wd-card>
<!-- 详情弹窗 -->
<wd-popup v-model="detailDialogVisible" position="bottom">
<wd-cell-group>
<wd-cell title="操作人" :value="logDetail.operator" />
<wd-cell title="操作时间" :value="logDetail.createTime" />
<wd-cell title="模块" :value="logDetail.module" />
<wd-cell title="内容" :value="logDetail.content" />
<wd-cell title="IP" :value="logDetail.ip" />
<wd-cell title="地区" :value="logDetail.region" />
<wd-cell title="浏览器" :value="logDetail.region" />
<wd-cell title="终端系统" :value="logDetail.os" />
<wd-cell title="耗时(毫秒)" :value="logDetail.executionTime" />
</wd-cell-group>
</wd-popup>
<!-- 加载更多 -->
<wd-loadmore v-if="total > 0" :state="loadMoreState" @reload="loadmore" />
<wd-status-tip v-else-if="total == 0" image="search" tip="当前搜索无结果" />
</view>
</template>
<script lang="ts" setup>
import { onLoad, onReachBottom } from "@dcloudio/uni-app";
import { LoadMoreState } from "wot-design-uni/components/wd-loadmore/types";
import { DropMenuItemExpose } from "wot-design-uni/components/wd-drop-menu-item/types";
import LogAPI, { LogVO, LogPageQuery } from "@/api/system/log";
const filterDropMenu = ref<DropMenuItemExpose>();
const loadMoreState = ref<LoadMoreState>("loading");
const queryParams = reactive<LogPageQuery>({
pageNum: 1,
pageSize: 10,
});
const total = ref(0);
const pageData = ref<LogVO[]>([]);
const logDetail = ref<LogVO>({});
const detailDialogVisible = ref(false);
/**
* 搜索栏
*/
function handleQuery() {
filterDropMenu.value?.close();
queryParams.pageNum = 1;
loadmore();
}
/**
* 重置搜索
*/
function handleResetQuery() {
queryParams.keywords = undefined;
queryParams.createTime = undefined;
handleQuery();
}
/**
* 加载更多
*/
function loadmore() {
loadMoreState.value = "loading";
LogAPI.getPage(queryParams)
.then((data) => {
pageData.value = data.list;
total.value = data.total;
queryParams.pageNum++;
})
.catch(() => {
pageData.value = [];
})
.finally(() => {
loadMoreState.value = "finished";
});
}
/**
* 查看详情
*/
function handleViewDetail(item: LogVO) {
detailDialogVisible.value = true;
logDetail.value = item;
}
/**
* 触底事件
*/
onReachBottom(() => {
if (queryParams.pageNum * queryParams.pageSize < total.value) {
loadmore();
} else if (queryParams.pageNum * queryParams.pageSize >= total.value) {
loadMoreState.value = "finished";
}
});
onLoad(() => {
handleQuery();
});
</script>
<style lang="scss" scoped>
.wd-col {
margin-top: 10rpx;
}
::v-deep .wd-drop-menu__item {
display: flex;
justify-content: flex-end;
padding: 0 50rpx;
}
.card-list {
:deep(.wd-cell__wrapper) {
padding: 4rpx 0;
}
:deep(.wd-cell) {
padding-right: 10rpx;
background: #f8f8f8;
}
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<div class="notice-detail">
<wd-cell-group>
<div class="notice-header">
<h2 class="notice-title">
<wd-icon name="prompt" size="20px" class="title-icon" />
{{ noticeDetail.title }}
</h2>
<div class="notice-meta">
<div class="meta-row">
优先级
<cu-dict-label code="notice_level" :model-value="noticeDetail.level" />
</div>
<div class="meta-row">发布人{{ noticeDetail.publisherName }}</div>
<div class="meta-row">发布时间{{ noticeDetail.publishTime }}</div>
</div>
</div>
<wd-divider />
<div class="notice-content" v-html="noticeDetail.content" />
</wd-cell-group>
</div>
</template>
<script setup lang="ts">
import NoticeAPI, { NoticeDetailVO } from "@/api/system/notice";
const noticeDetail = ref<NoticeDetailVO>({});
/**
* 获取通知详情
* @param id 通知ID
*/
const getNoticeDetail = async (id: string) => {
try {
const res = await NoticeAPI.getDetail(id);
noticeDetail.value = res;
} catch (error) {
console.error("获取通知详情失败:", error);
}
};
// 页面加载
onLoad((options: any) => {
if (options && options.id) {
getNoticeDetail(options.id as string);
} else {
uni.showToast({
title: "通知不存在",
icon: "none",
});
}
});
</script>
<style scoped>
.notice-detail {
padding: 12px;
background: #f7f8fa;
}
.notice-header {
padding: 16px;
background: #fff;
border-radius: 8px;
}
.notice-title {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
margin-bottom: 12px;
font-size: 18px;
font-weight: 500;
line-height: 1.4;
text-align: center;
}
.title-icon {
color: #666;
}
.notice-meta {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
margin-bottom: 12px;
font-size: 13px;
color: #666;
}
.meta-row {
display: flex;
flex-wrap: nowrap;
gap: 8px;
align-items: center;
}
.priority-wrapper {
display: inline-flex;
gap: 4px;
align-items: center;
}
.notice-content {
padding: 16px;
font-size: 15px;
line-height: 1.6;
background: #fff;
border-radius: 8px;
}
:deep(.wd-divider) {
margin: 0 8px;
}
</style>

View File

@ -0,0 +1,284 @@
<template>
<view>
<!-- 添加搜索栏 -->
<wd-drop-menu close-on-click-modal class="mb-20rpx mr-20rpx ml-20rpx">
<wd-drop-menu-item ref="dropMenu" title="筛选" icon="filter" icon-size="18px">
<view>
<wd-input
v-model="queryParams.title"
label="关键字"
type="text"
placeholder="请输入关键字"
/>
<view class="flex flex-row items-center mb-20rpx">
<wd-button class="mt-20rpx mb-20rpx" size="medium" @click="handleQuery()">
查询
</wd-button>
<wd-button size="medium" type="info" @click="handleReset">重置</wd-button>
</view>
</view>
</wd-drop-menu-item>
</wd-drop-menu>
<!-- 列表内容 -->
<view class="data-container">
<view v-for="(item, index) in dataList" :key="index" class="mt-20rpx">
<wd-card>
<template #title>
<view class="flex items-center justify-between">
<view class="flex-1 text-truncate">{{ item.title }}</view>
<wd-tag :type="getStatusType(item.publishStatus)" size="small">
{{ getStatusText(item.publishStatus) }}
</wd-tag>
</view>
</template>
<wd-cell-group>
<wd-cell title="通告目标类型" :value="item.targetType === 1 ? '指定' : '全体'" />
<wd-cell title="紧急程度">
<template #default>
<cu-dict-label code="notice_level" :model-value="item.level" />
</template>
</wd-cell>
<wd-cell title="发布人" :value="item.publisherName || '-'" />
</wd-cell-group>
<template #footer>
<view class="flex-between">
<view v-if="item.publishStatus === 1" class="text-left">
<wd-text text="发布时间:" size="small" class="font-bold" />
<wd-text :text="formatDate(item.publishTime)" size="small" />
</view>
<view v-else class="text-left">
<wd-text text="撤回时间:" size="small" class="font-bold" />
<wd-text :text="formatDate(item.revokeTime)" size="small" />
</view>
<view class="text-right">
<wd-button size="small" plain type="primary" @click="handleAction(item)">
操作
</wd-button>
</view>
</view>
</template>
</wd-card>
</view>
</view>
<!-- 加载更多 -->
<wd-loadmore custom-class="loadmore" :state="loadState" />
</view>
</template>
<script lang="ts" setup>
import { LoadMoreState } from "wot-design-uni/components/wd-loadmore/types";
import { DropMenuItemExpose } from "wot-design-uni/components/wd-drop-menu-item/types";
import NoticeAPI, { NoticePageQuery, NoticePageVO } from "@/api/system/notice";
const loadState = ref<LoadMoreState>("finished");
const dataList = ref<NoticePageVO[]>([]);
const total = ref(0);
// 修改查询参数
const queryParams = ref<NoticePageQuery>({
pageNum: 1,
pageSize: 10,
title: "",
});
// 添加搜索处理函数
const dropMenu = ref<DropMenuItemExpose>();
const handleQuery = () => {
queryParams.value.pageNum = 1;
loadMore();
dropMenu.value?.close();
};
// 重置
const handleReset = () => {
queryParams.value = { pageNum: 1, pageSize: 10 };
dropMenu.value?.close();
loadMore();
};
// 获取状态样式
const getStatusType = (
status: number | undefined
): "default" | "primary" | "danger" | "warning" | "success" => {
if (!status) return "default";
const statusMap: Record<number, "default" | "primary" | "danger" | "warning" | "success"> = {
0: "primary",
1: "success",
[-1]: "warning",
};
return statusMap[status] || "default";
};
// 获取状态文本
const getStatusText = (status: number | undefined): string => {
if (status !== 0 && !status) return "-";
const statusMap: Record<number, string> = {
0: "未发布",
1: "已发布",
[-1]: "已撤回",
};
return statusMap[status] || "未知";
};
// 格式化日期
const formatDate = (date: Date | undefined): string => {
return date ? date.toString() : "-";
};
// 加载更多
const loadMore = async () => {
if (loadState.value === "loading") return;
loadState.value = "loading";
const { list, total: totalCount } = await NoticeAPI.getPage(queryParams.value);
if (queryParams.value.pageNum === 1) {
dataList.value = list;
} else {
dataList.value = [...dataList.value, ...list];
}
total.value = totalCount;
queryParams.value.pageNum++;
loadState.value = dataList.value.length >= total.value ? "finished" : "loading";
};
// 查看详情
const handleView = (notice: NoticePageVO) => {
uni.navigateTo({
url: `/pages/work/notice/detail?id=${notice.id}`,
});
};
// 操作按钮
const handleAction = (notice: NoticePageVO) => {
const actions = notice.publishStatus !== 1 ? ["查看", "删除", "发布"] : ["查看", "撤回"];
uni.showActionSheet({
itemList: actions,
success: ({ tapIndex }) => {
switch (actions[tapIndex]) {
case "查看":
handleView(notice);
break;
case "删除":
handleDelete(notice);
break;
case "发布":
handlePublish(notice);
break;
case "撤回":
handleRevoke(notice);
break;
}
},
});
};
// 删除
const handleDelete = (notice: NoticePageVO) => {
uni.showModal({
title: "提示",
content: "确定要删除该通知吗?",
success: async (res) => {
if (res.confirm) {
try {
await NoticeAPI.deleteByIds(notice.id);
uni.showToast({ title: "删除成功", icon: "success" });
// 重新加载第一页
queryParams.value.pageNum = 1;
loadMore();
} catch (error) {
uni.showToast({ title: "删除失败" + error, icon: "none" });
}
}
},
});
};
// 发布
const handlePublish = (notice: NoticePageVO) => {
uni.showModal({
title: "提示",
content: "确定要发布该通知吗?",
success: async (res) => {
if (res.confirm) {
try {
await NoticeAPI.publish(Number(notice.id));
uni.showToast({ title: "发布成功", icon: "success" });
// 重新加载第一页
queryParams.value.pageNum = 1;
loadMore();
} catch (error) {
uni.showToast({ title: "发布失败" + error, icon: "none" });
}
}
},
});
};
// 撤回
const handleRevoke = (notice: NoticePageVO) => {
uni.showModal({
title: "提示",
content: "确定要撤回该通知吗?",
success: async (res) => {
if (res.confirm) {
try {
await NoticeAPI.revoke(Number(notice.id));
uni.showToast({
title: "撤回成功",
icon: "success",
});
// 刷新列表
queryParams.value.pageNum = 1;
loadMore();
} catch (error) {
uni.showToast({
title: "撤回失败" + error,
icon: "error",
});
}
}
},
});
};
onReachBottom(() => {
if (loadState.value === "loading" || loadState.value === "finished") return;
loadMore();
});
onLoad(() => {
loadMore();
});
</script>
<style lang="scss" scoped>
.data-container {
:deep(.wd-cell__wrapper) {
padding: 4rpx 0;
}
:deep(.wd-cell) {
padding-right: 10rpx;
background: #f8f8f8;
}
:deep(.wd-fab__trigger) {
width: 80rpx !important;
height: 80rpx !important;
}
.filter-container {
padding: 10rpx;
background: #fff;
}
.data-container {
margin-top: 20rpx;
}
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<view>
<view class="p-l-8 p-b-20">
<DaTree
ref="DaTreeRef"
:data="menuPermOptions"
:defaultExpandAll="true"
:showCheckbox="true"
/>
</view>
<wd-fab v-model:active="showFab" type="primary" position="right-top" direction="bottom">
<wd-button custom-class="custom-button" type="primary" @click="doCheckedTree(rootKeys, true)">
</wd-button>
<wd-button custom-class="custom-button" type="error" @click="doCheckedTree(rootKeys, false)">
全不选
</wd-button>
<wd-button custom-class="custom-button" type="success" @click="doExpandTree('all', true)">
全展开
</wd-button>
<wd-button custom-class="custom-button" type="warning" @click="doExpandTree('all', false)">
全收起
</wd-button>
</wd-fab>
<!-- 底部按钮 -->
<view class="footer-buttons" style="">
<wd-button size="medium" type="primary" block @click="handleAssignPermSubmit">
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import RoleAPI from "@/api/system/role";
import MenuAPI from "@/api/system/menu";
import DaTree from "@/components/da-tree/index.vue";
let roleId = ref<number>(); // 操作的角色ID
const menuPermOptions = ref<OptionType[]>([]); // 所有菜单
let rootKeys = ref<number[]>([]); // 根节点数组
const DaTreeRef = ref(); // 树引用
const showFab = ref(); // 悬浮按钮是否展开
/**
* 分配菜单权限页面数据初始化
*/
async function initAssignPerm() {
// 获取所有的菜单
menuPermOptions.value = await MenuAPI.getOptions(false);
rootKeys.value = menuPermOptions.value.map((item) => item.value as number);
// 回显角色已拥有的菜单
RoleAPI.getRoleMenuIds(roleId.value!).then((data) => {
const checkedMenuIds = data;
if (checkedMenuIds && checkedMenuIds.length > 0) {
DaTreeRef.value?.setCheckedKeys(checkedMenuIds, true);
}
});
}
// 展开/收起树节点
function doExpandTree(keys: number[] | string, expand: boolean) {
DaTreeRef.value?.setExpandedKeys(keys, expand);
showFab.value = false;
}
// 选中/取消树节点
function doCheckedTree(keys: number[], checked: boolean) {
DaTreeRef.value?.setCheckedKeys(keys, checked);
showFab.value = false;
}
// 分配菜单权限提交
function handleAssignPermSubmit() {
if (roleId.value) {
const checkedMenuIds: number[] = DaTreeRef.value?.getCheckedKeys();
RoleAPI.updateRoleMenus(roleId.value, checkedMenuIds).then(() => {
uni.showToast({ title: "分配权限成功", icon: "success" });
uni.navigateBack({ delta: 1 });
});
}
}
onLoad((options: any) => {
if (options && options.id) {
roleId.value = options.id;
}
initAssignPerm();
});
</script>
<style lang="scss" scoped>
.footer-buttons {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 100;
padding: 20rpx;
background-color: #fff;
}
:deep(.custom-button) {
box-sizing: border-box;
width: 64px !important;
min-width: auto !important;
height: 32px !important;
margin: 8rpx;
border-radius: 16px !important;
}
</style>

View File

@ -0,0 +1,356 @@
<template>
<view class="role">
<!-- 筛选 -->
<wd-drop-menu>
<wd-drop-menu-item ref="filterDropMenu" icon="filter" icon-size="18px" title="筛选">
<view>
<wd-input
v-model="queryParams.keywords"
label="关键字"
type="text"
placeholder="请输入关键字"
/>
<view class="flex-between py-2">
<wd-button custom-class="w-20%" type="info" @click="handleResetQuery">重置</wd-button>
<wd-button custom-class="w-70%" @click="handleQuery">确定</wd-button>
</view>
</view>
</wd-drop-menu-item>
</wd-drop-menu>
<!-- 卡片列表 -->
<view class="list-container">
<wd-card v-for="item in dataList" :key="item.id" class="role-card">
<template #title>
<view class="flex-between">
<view class="flex-center">
<view class="ml-2">
<view class="font-bold">
{{ item.name }}
</view>
</view>
</view>
<view>
<wd-tag v-if="item.status === 1" type="success" plain>正常</wd-tag>
<wd-tag v-else-if="item.status === 0" plain>停用</wd-tag>
</view>
</view>
</template>
<wd-cell-group>
<wd-cell title="编码" title-width="150rpx" :value="item.code" />
<wd-cell title="排序号" title-width="150rpx" :value="item.sort" />
</wd-cell-group>
<template #footer>
<view class="flex-between">
<view class="text-left">
<wd-text text="创建时间:" size="small" class="font-bold" />
<wd-text :text="item.createTime" size="small" />
</view>
<view class="flex-right">
<wd-button size="small" plain @click="handleAction(item)">操作</wd-button>
</view>
</view>
</template>
</wd-card>
<!-- 加载更多 -->
<wd-loadmore :state="loadMoreState" @reload="queryPageData" />
</view>
<!-- 底部按钮 -->
<wd-fab
position="left-bottom"
:expandable="false"
customStyle="width: 1rem; height: 1rem; line-height: 1rem;z-index:9"
@click="handleOpenDialog()"
/>
<wd-popup v-model="dialog.visible" position="bottom" custom-class="yl-popup">
<wd-form ref="roleFormRef" :model="formData" :rules="rules">
<wd-cell-group border>
<wd-input v-model="formData.name" label="角色名称" prop="name" />
<wd-input v-model="formData.code" label="角色编码" prop="code" />
<wd-select-picker
v-model="formData.dataScope"
type="radio"
label="数据权限"
:columns="dataScopeOptions"
:align-right="true"
prop="dataScope"
/>
<wd-cell title="状态" prop="status">
<wd-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
</wd-cell>
<wd-cell title="排序" prop="sort">
<wd-input-number v-model="formData.sort" />
</wd-cell>
</wd-cell-group>
</wd-form>
<view class="footer">
<wd-button type="primary" block @click="handleSubmit">提交</wd-button>
</view>
</wd-popup>
<wd-message-box />
</view>
</template>
<script lang="ts" setup>
import RoleAPI, { RolePageVO, RolePageQuery, RoleForm } from "@/api/system/role";
import { onLoad, onReachBottom } from "@dcloudio/uni-app";
import { LoadMoreState } from "wot-design-uni/components/wd-loadmore/types";
import { DropMenuItemExpose } from "wot-design-uni/components/wd-drop-menu-item/types";
import { FormInstance } from "wot-design-uni/components/wd-form/types";
import { FormRules } from "wot-design-uni/components/wd-form/types";
import { useMessage } from "wot-design-uni";
const message = useMessage();
const loadMoreState = ref<LoadMoreState>("loading");
const total = ref(0);
const queryParams = reactive<RolePageQuery>({
pageNum: 1,
pageSize: 10,
});
/**
* 搜索栏
*/
const filterDropMenu = ref<DropMenuItemExpose>();
function handleQuery() {
filterDropMenu.value?.close();
queryParams.pageNum = 1;
dataList.value = [];
queryPageData();
}
/**
* 重置查询
*/
const handleResetQuery = () => {
filterDropMenu.value?.close();
queryParams.keywords = "";
queryParams.pageNum = 1;
dataList.value = [];
queryPageData();
};
// 角色列表数据
const dataList = ref<RolePageVO[]>([]);
/**
* 查询分页数据
*/
function queryPageData() {
loadMoreState.value = "loading";
RoleAPI.getPage(queryParams)
.then((data) => {
dataList.value?.push(...data.list);
total.value = data.total;
})
.catch((e) => {
console.log("系统异常", e);
})
.finally(() => {
loadMoreState.value = "finished";
});
}
/**
* 触底事件
*/
onReachBottom(() => {
if (queryParams.pageNum * queryParams.pageSize < total.value) {
queryParams.pageNum++;
queryPageData();
} else {
loadMoreState.value = "finished";
}
});
// 操作按钮
const handleAction = (item: RolePageVO) => {
const actions = ["编辑", "分配权限", "删除"];
uni.showActionSheet({
itemList: actions,
success: ({ tapIndex }) => {
switch (actions[tapIndex]) {
case "编辑":
handleOpenDialog(item.id);
break;
case "分配权限":
handleAssignPerm(item.id);
break;
case "删除":
handleDelete(item.id);
break;
}
},
});
};
const dialog = reactive({
visible: false,
});
const dataScopeOptions = ref<Record<string, any>[]>([
{ label: "全部数据", value: 0 },
{ label: "部门及子部门数据", value: 1 },
{ label: "本部门数据", value: 2 },
{ label: "本人数据", value: 3 },
]);
const formData = reactive<RoleForm>({
dataScope: 0,
sort: 1,
});
const rules: FormRules = {
name: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
code: [{ required: true, message: "请输入角色编码", trigger: "blur" }],
dataScope: [{ required: true, message: "请选择数据权限", trigger: "blur" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }],
sort: [{ required: true, message: "请输入排序号", trigger: "change" }],
};
const roleFormRef = ref<FormInstance>();
/**
* 打开弹窗
*/
async function handleOpenDialog(id?: number) {
dialog.visible = true;
formData.id = undefined;
formData.name = "";
formData.code = "";
formData.dataScope = 0;
formData.status = 1;
formData.sort = 1;
if (id) {
RoleAPI.getFormData(id).then((data) => {
Object.assign(formData, { ...data });
});
}
}
/**
* 提交保存
*/
function handleSubmit() {
if (roleFormRef.value) {
roleFormRef.value.validate().then(({ valid }) => {
if (valid) {
const roleId = formData.id;
if (roleId) {
RoleAPI.update(roleId, formData).then(() => {
uni.showToast({ title: "修改成功", icon: "success" });
dialog.visible = false;
queryParams.pageNum = 1;
handleQuery();
});
} else {
RoleAPI.add(formData).then(() => {
uni.showToast({ title: "添加成功", icon: "success" });
dialog.visible = false;
queryParams.pageNum = 1;
handleQuery();
});
}
}
});
}
}
/**
* 删除
*
* @param id 用户id
*/
function handleDelete(id: number) {
message
.confirm({
msg: "确认删除角色吗?",
title: "提示",
})
.then(() => {
RoleAPI.deleteByIds(id + "").then(() => {
uni.showToast({ title: "删除成功", icon: "success" });
queryParams.pageNum = 1;
handleQuery();
});
})
.catch(() => {
console.log("点击了取消按钮");
});
}
function handleAssignPerm(id: number) {
uni.navigateTo({
url: "/pages/work/role/assign-perm?id=" + id,
});
}
onLoad(() => {
handleQuery();
});
</script>
<script lang="ts">
// https://wot-design-uni.pages.dev/guide/common-problems#%E5%B0%8F%E7%A8%8B%E5%BA%8F%E6%A0%B7%E5%BC%8F%E9%9A%94%E7%A6%BB
export default {
options: {
styleIsolation: "shared",
},
};
</script>
<style lang="scss" scoped>
.role {
:deep(.wd-drop-menu .wd-drop-menu__item) {
display: flex;
justify-content: flex-end;
padding: 0 50rpx;
}
.list-container {
.role-card {
margin-top: 20rpx;
:deep(.wd-cell__wrapper) {
padding: 4rpx 0;
}
:deep(.wd-cell) {
padding-right: 10rpx;
background: #f8f8f8;
}
}
}
:deep(.wd-cell__wrapper) {
padding: 4rpx 0;
}
:deep(.wd-cell) {
padding-right: 10rpx;
background: #f8f8f8;
}
:deep(.wd-fab__trigger) {
width: 80rpx !important;
height: 80rpx !important;
}
.yl-popup {
.footer {
margin: 30rpx 0;
}
}
/**
* wot组件直接写class在小程序上无效
* wot组件提供的custom-class属性也不能写unocss的写法
* 故wot组件只能使用custom-class属性并在vue文件中自己写样式
* 并且custom-class的类名需要使用:deep包裹否则在小程序上也没有效果
*/
:deep(.w-20per) {
width: 20%;
}
:deep(.w-70per) {
width: 70%;
}
}
</style>

View File

@ -0,0 +1,426 @@
<template>
<!-- 自定义导航栏的占位 -->
<view style="width: 100%; height: var(--status-bar-height)" />
<view class="user-container">
<!-- 自定义导航栏 -->
<wd-navbar title="用户管理">
<template #left>
<wd-icon name="thin-arrow-left" @click="handleNavigateback()" />
</template>
</wd-navbar>
<!-- 排序筛选 -->
<view class="filter-container">
<wd-drop-menu>
<wd-drop-menu-item
v-model="sortValue"
:options="sortOptions"
title="排序"
icon="unfold-more"
@change="handleSortChange"
/>
<wd-drop-menu-item ref="filterDropMenu" title="筛选" icon="filter">
<view>
<wd-input
v-model="queryParams.keywords"
label="关键字"
placeholder="用户名/昵称/手机号"
/>
<cu-date-query v-model="queryParams.createTime" label="创建时间" />
<view class="flex-between py-2">
<wd-button type="info" @click="handleResetQuery">重置</wd-button>
<wd-button @click="handleQuery">查询</wd-button>
</view>
</view>
</wd-drop-menu-item>
</wd-drop-menu>
</view>
<!-- 数据列表 -->
<view class="data-container">
<wd-card v-for="item in pageData" :key="item.id">
<template #title>
<view class="flex-between">
<view class="flex-center">
<wd-img :width="50" :height="50" round :src="item.avatar" />
<view class="ml-2">
<view class="font-bold">
{{ item.nickname }}
<wd-icon v-if="item.gender == 1" name="gender-male" class="color-#4D80F0" />
<wd-icon
v-else-if="item.gender == 2"
name="gender-female"
class="color-#FA4350"
/>
</view>
<view class="mt-1"><wd-text :text="item.deptName" size="12px" /></view>
</view>
</view>
<view>
<wd-tag v-if="item.status === 1" type="success" plain>正常</wd-tag>
<wd-tag v-else-if="item.status === 0" plain>禁用</wd-tag>
</view>
</view>
</template>
<wd-cell-group>
<wd-cell title="用户名" :value="item.username" icon="user" />
<wd-cell title="角色" :value="item.roleNames" icon="usergroup" />
<wd-cell title="手机号码" :value="item.mobile" icon="mobile" />
<wd-cell title="邮箱" :value="item.email" icon="mail" />
</wd-cell-group>
<template #footer>
<view class="flex-between">
<view class="text-left">
<wd-text text="创建时间:" size="small" class="font-bold" />
<wd-text :text="item.createTime" size="small" />
</view>
<view class="text-right">
<wd-button
type="primary"
size="small"
plain
:v-has-perm="'sys:user:edit'"
@click="handleOpenDialog(item.id)"
>
编辑
</wd-button>
&nbsp;
<wd-button
type="error"
size="small"
plain
:v-has-perm="'sys:user:delete'"
@click="handleDelete(item.id)"
>
删除
</wd-button>
</view>
</view>
</template>
</wd-card>
<wd-loadmore v-if="total > 0" :state="loadMoreState" @reload="loadmore" />
<wd-status-tip v-else-if="total == 0" image="search" tip="当前搜索无结果" />
</view>
<!-- 弹窗表单 -->
<wd-popup v-model="dialog.visible" position="bottom" @close="hancleCloseDialog">
<wd-form ref="userFormRef" :model="formData" :rules="rules">
<wd-cell-group border>
<wd-input v-model="formData.username" label="用户名" :readonly="!formData.id" required />
<wd-input v-model="formData.nickname" label="昵称" required />
<wd-select-picker
v-model="formData.roleIds"
label="角色"
:columns="roleOptions"
required
/>
<CuPicker
v-model="formData.deptId"
v-model:data="deptOptions"
label="部门"
:required="true"
/>
<wd-input v-model="formData.mobile" label="手机号" prop="mobile" />
<wd-input v-model="formData.email" label="邮箱" prop="email" />
<wd-cell title="状态">
<wd-switch v-model="formData.status" :active-value="1" :inactive-value="0" required />
</wd-cell>
</wd-cell-group>
</wd-form>
<view class="popup-footer">
<wd-button type="primary" block @click="handleSubmit">提交</wd-button>
</view>
</wd-popup>
<!-- 悬浮操作按钮 -->
<wd-fab
:v-has-perm="'sys:user:add'"
position="left-bottom"
:expandable="false"
custom-style="z-index: 9"
@click="handleOpenDialog"
/>
<wd-message-box />
</view>
</template>
<script lang="ts" setup>
import { onLoad, onReachBottom } from "@dcloudio/uni-app";
import { LoadMoreState } from "wot-design-uni/components/wd-loadmore/types";
import { FormRules } from "wot-design-uni/components/wd-form/types";
import { useMessage } from "wot-design-uni";
import UserAPI, { type UserPageQuery, UserPageVO, UserForm } from "@/api/system/user";
import RoleAPI from "@/api/system/role";
import DeptAPI from "@/api/system/dept";
const message = useMessage();
const loadMoreState = ref<LoadMoreState>("loading");
const filterDropMenu = ref();
const userFormRef = ref();
const sortValue = ref(0);
const sortOptions = ref<Record<string, any>[]>([
{ label: "默认排序", value: 0 },
{ label: "最近创建", value: 1 },
{ label: "最近更新", value: 2 },
]);
let queryParams: UserPageQuery = {
pageNum: 1,
pageSize: 10,
};
const total = ref(0);
const pageData = ref<UserPageVO[]>([]);
const dialog = reactive({
visible: false,
});
const initialFormData: UserForm = {
id: undefined,
roleIds: [],
username: undefined,
nickname: undefined,
deptId: undefined,
mobile: undefined,
email: undefined,
status: 1,
};
const formData = reactive<UserForm>({ ...initialFormData });
const roleOptions = ref<Record<string, any>[]>([]);
const deptOptions = ref<OptionType[]>([]);
const rules: FormRules = {
username: [{ required: true, message: "请输入用户名" }],
nickname: [{ required: true, message: "请输入昵称" }],
roleIds: [{ required: true, message: "请选择角色" }],
deptId: [{ required: true, message: "请选择部门" }],
status: [{ required: true, message: "请选择状态" }],
mobile: [
{
required: false,
message: "手机号格式不正确",
validator: (value) => {
if (!value) {
return Promise.resolve();
}
if (!/^1[3456789]\d{9}$/.test(value)) {
return Promise.reject("手机号格式不正确");
} else {
return Promise.resolve();
}
},
},
],
email: [
{
required: false,
message: "邮箱格式不正确",
validator: (value) => {
if (!value) {
return Promise.resolve();
}
if (!/^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/.test(value)) {
return Promise.reject("邮箱格式不正确");
} else {
return Promise.resolve();
}
},
},
],
};
/**
* 排序改变
*/
const handleSortChange = ({ value }: { value: number }) => {
if (value === 1) {
queryParams.field = "create_time";
queryParams.direction = "DESC";
} else if (value === 2) {
queryParams.field = "update_time";
queryParams.direction = "DESC";
} else {
queryParams.field = "";
queryParams.direction = "";
}
handleQuery();
};
/**
* 查询
*/
const handleQuery = () => {
filterDropMenu.value?.close();
queryParams.pageNum = 1;
loadmore();
console.log("queryParams", queryParams);
};
/**
* 重置查询
*/
const handleResetQuery = () => {
queryParams = {
pageNum: 1,
pageSize: 10,
};
handleQuery();
};
/**
* 加载更多
*/
function loadmore() {
loadMoreState.value = "loading";
UserAPI.getPage(queryParams)
.then((data) => {
pageData.value = data.list;
total.value = data.total;
queryParams.pageNum++;
})
.catch((e) => {
pageData.value = [];
})
.finally(() => {
loadMoreState.value = "finished";
});
}
/**
* 打开弹窗
*/
async function handleOpenDialog(id?: number) {
dialog.visible = true;
roleOptions.value = await RoleAPI.getOptions();
deptOptions.value = await DeptAPI.getOptions();
if (id) {
UserAPI.getFormData(id).then((data) => {
Object.assign(formData, { ...data });
});
}
}
/**
* 提交保存
*/
function handleSubmit() {
hancleCloseDialog();
userFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
const userId = formData.id;
if (userId) {
UserAPI.update(userId, formData).then(() => {
message.show("修改成功");
hancleCloseDialog();
handleQuery();
});
} else {
UserAPI.add(formData).then(() => {
message.show("添加成功");
hancleCloseDialog();
handleQuery();
});
}
}
});
}
// 重置表单
function resetForm() {
userFormRef.value.reset();
Object.assign(formData, initialFormData);
}
// 关闭弹窗
function hancleCloseDialog() {
dialog.visible = false;
resetForm();
}
/**
* 删除
*
* @param id 用户id
*/
function handleDelete(id: number) {
message
.confirm({
msg: "确认删除用户吗?",
title: "提示",
})
.then(() => {
UserAPI.deleteByIds(id + "").then(() => {
message.show("删除成功");
handleQuery();
});
});
}
/**
* 返回
*/
function handleNavigateback() {
uni.navigateBack();
}
// 触底事件
onReachBottom(() => {
if (queryParams.pageNum * queryParams.pageSize < total.value) {
loadmore();
} else if (queryParams.pageNum * queryParams.pageSize >= total.value) {
loadMoreState.value = "finished";
}
});
onLoad(() => {
handleQuery();
});
</script>
<script lang="ts">
// https://wot-design-uni.pages.dev/guide/common-problems#%E5%B0%8F%E7%A8%8B%E5%BA%8F%E6%A0%B7%E5%BC%8F%E9%9A%94%E7%A6%BB
export default {
options: {
styleIsolation: "shared",
},
};
</script>
<style lang="scss" scoped>
.user-container {
:deep(.wd-cell__wrapper) {
padding: 4rpx 0;
}
:deep(.wd-cell) {
padding-right: 10rpx;
background: #f8f8f8;
}
:deep(.wd-fab__trigger) {
width: 80rpx !important;
height: 80rpx !important;
}
.filter-container {
padding: 10rpx;
background: #fff;
}
.data-container {
margin-top: 20rpx;
}
}
</style>