家谱APP首页样式设计完成,落地80%

This commit is contained in:
2026-07-13 00:23:04 +08:00
commit 1da4d8cf1f
117 changed files with 9476 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
<template>
<view class="page-shell login-page">
<view class="ceremony-top">
<text class="seal-word"></text>
<text class="eyebrow">家脉绵延 · 生生不息</text>
<text class="brand-title">家谱</text>
<text class="brand-subtitle">为家族留存可传承的记忆</text>
</view>
<view class="login-paper">
<view class="section-title">登录家谱</view>
<text class="form-note">使用已注册手机号和密码登录</text>
<text class="form-label">手机号</text>
<input v-model="phone" class="line-input" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="placeholder" />
<text class="form-label">密码</text>
<input v-model="password" class="line-input" password placeholder="请输入密码" placeholder-class="placeholder" />
<text v-if="errorMessage" class="error-message">{{ errorMessage }}</text>
<button class="primary-button login-button" :loading="isSubmitting" :disabled="isSubmitting" @click="login">进入家谱</button>
<text class="login-tip">短信登录将在验证码服务联调后开放</text>
<text class="terms">登录即表示同意用户协议隐私政策</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { appApi } from '@/utils/api.js'
import { calcMD5 } from '@/utils/md5.js'
const phone = ref('')
const password = ref('')
const isSubmitting = ref(false)
const errorMessage = ref('')
const login = async () => {
if (!/^1\d{10}$/.test(phone.value)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (!password.value) {
uni.showToast({ title: '请输入登录密码', icon: 'none' })
return
}
isSubmitting.value = true
errorMessage.value = ''
try {
await appApi.loginWithPassword({ phone: phone.value, passwordHash: calcMD5(password.value) })
uni.reLaunch({ url: '/pages/genealogy/index' })
} catch (error) {
errorMessage.value = error.message || '登录失败,请稍后重试'
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped lang="scss">
.login-page { padding-top: 108rpx; }
.ceremony-top {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 40rpx 76rpx;
background: $brand-red;
color: #fff9ed;
}
.seal-word { display: flex; width: 66rpx; height: 66rpx; align-items: center; justify-content: center; box-sizing: border-box; border: 2rpx solid #f1d494; border-radius: 50%; color: #f5ddaa; font-family: serif; font-size: 37rpx; }
.eyebrow { margin-top: 24rpx; color: #f8dfad; font-size: 22rpx; letter-spacing: 5rpx; }
.brand-title { margin-top: 24rpx; font-family: serif; font-size: 68rpx; font-weight: 700; letter-spacing: 16rpx; }
.brand-subtitle { margin-top: 18rpx; color: #f5deae; font-size: 26rpx; letter-spacing: 3rpx; }
.login-paper { margin: -28rpx 28rpx 0; padding: 48rpx 42rpx; border: 1rpx solid $gold-soft; border-radius: 24rpx; background: $paper-white; }
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 23rpx; }
.form-label { display: block; margin-top: 40rpx; color: $ink-muted; font-size: 25rpx; }
.line-input { height: 78rpx; border-bottom: 1rpx solid $gold-soft; color: $ink; font-size: 30rpx; }
.placeholder { color: #b3a895; }
.error-message { display: block; margin-top: 22rpx; color: $brand-red; font-size: 23rpx; line-height: 1.5; }
.login-button { margin-top: 48rpx; }
.login-tip { display: block; margin-top: 24rpx; color: $ink-muted; font-size: 22rpx; text-align: center; }
.terms { display: block; margin-top: 16rpx; color: #a39785; font-size: 21rpx; text-align: center; }
</style>
+35
View File
@@ -0,0 +1,35 @@
<template>
<view class="page-shell editor-page">
<PageHeader title="发布家族动态" />
<view class="form-paper"><view class="section-title">记录此刻</view><text class="form-note">发布后当前家谱的成员可以在家族圈查看</text><textarea v-model="feedContent" maxlength="300" placeholder="写下通知、活动、家族故事或照片说明" placeholder-class="placeholder" /></view>
<view class="bottom-action"><button class="primary-button" :loading="isSubmitting" :disabled="isSubmitting" @click="submit">发布动态</button></view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const genealogyId = ref('')
const feedContent = ref('')
const isSubmitting = ref(false)
onLoad((query) => { genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() })
const submit = async () => {
if (!genealogyId.value) { uni.showToast({ title: '未找到当前家谱', icon: 'none' }); return }
if (!feedContent.value.trim()) { uni.showToast({ title: '请填写动态内容', icon: 'none' }); return }
isSubmitting.value = true
try { await appApi.createFeed(genealogyId.value, { feedType: 'text', feedContent: feedContent.value.trim() }); uni.navigateBack() } catch (error) { uni.showToast({ title: error.message || '发布失败,请稍后重试', icon: 'none' }) } finally { isSubmitting.value = false }
}
</script>
<style scoped lang="scss">
.editor-page { padding-bottom: 136rpx; }
.form-paper { margin: 28rpx; padding: 34rpx 30rpx; border: 1rpx solid $gold-soft; border-radius: 22rpx; background: $paper-white; }
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
textarea { width: 100%; height: 360rpx; margin-top: 30rpx; color: $ink; font-size: 28rpx; line-height: 1.7; }
.placeholder { color: #b7aa97; }
.bottom-action { position: fixed; right: 0; bottom: 0; left: 0; padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fffaf0; border-top: 1rpx solid $gold-soft; }
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<view class="page-shell content-page">
<PageHeader :title="config.title" />
<view class="content-intro"><text>{{ config.copy }}</text></view>
<view class="content-list"><view v-for="item in items" :key="item.id" class="content-card paper-card"><text class="content-title">{{ item.title || item.articleTitle || item.albumName || item.ceremonyName || '家族记录' }}</text><text class="content-summary">{{ item.summary || item.content || item.description || item.feedContent || '内容待补充' }}</text><text class="content-date">{{ item.date || item.createTime || '' }}</text></view><view v-if="!items.length && !loadError" class="empty-copy">暂无{{ config.title }}</view></view>
<text v-if="loadError" class="error-copy">{{ loadError }}</text>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const type = ref('article')
const items = ref([])
const loadError = ref('')
const definitions = { article: { title: '谱文', copy: '记录家族历史、家训与珍贵文字。' }, album: { title: '相册', copy: '珍存家人、故土与仪式的影像。' }, ceremony: { title: '祭祀', copy: '查看家族祭祀安排与纪念活动。' }, record: { title: '族务', copy: '共同记录成长、备忘与家族事务。' } }
const config = computed(() => definitions[type.value] || definitions.article)
onLoad(async (query) => {
type.value = definitions[query.type] ? query.type : 'article'
const genealogyId = query.genealogyId || genealogyContext.getCurrentGenealogyId()
if (!genealogyId) { loadError.value = '请先选择一个家谱。'; return }
genealogyContext.setCurrentGenealogyId(genealogyId)
try { items.value = await appApi.getContent(type.value, genealogyId) } catch (error) { loadError.value = error.message || '内容加载失败。' }
})
</script>
<style scoped lang="scss">
.content-page { padding-bottom: 36rpx; }
.content-intro { padding: 28rpx 30rpx; background: #efe3cd; color: $ink-muted; font-size: 24rpx; }
.content-list { display: flex; flex-direction: column; gap: 18rpx; margin: 26rpx 28rpx; }
.content-card { padding: 28rpx; }
.content-title { display: block; color: $ink; font-size: 31rpx; font-weight: 700; }
.content-summary { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.65; }
.content-date { display: block; margin-top: 16rpx; color: $gold; font-size: 22rpx; }
.empty-copy { padding: 80rpx 0; color: $ink-muted; font-size: 26rpx; text-align: center; }
.error-copy { display: block; margin: 20rpx 28rpx; color: $brand-red; font-size: 24rpx; }
</style>
+60
View File
@@ -0,0 +1,60 @@
<template>
<view class="page-shell family-page">
<view class="family-top"><view><text class="top-kicker">家族共建</text><text class="page-name">家族记事</text></view><text class="publish" @click="publishFeed">发布动态</text></view>
<view v-if="loadError" class="error-copy">{{ loadError }}</view>
<view class="content-rail"><view v-for="item in sections" :key="item.type" class="rail-item" @click="openSection(item.type)"><text>{{ item.name }}</text><text>{{ item.detail }}</text></view></view>
<view class="feed-heading"><text class="section-title">家族近况</text><text class="feed-filter">全部动态</text></view>
<view class="feed-list"><view v-for="feed in feeds" :key="feed.id" class="feed-card paper-card"><view class="feed-meta"><text>{{ feed.type || feed.feedType || '动态' }}</text><text>{{ feed.date || feed.createTime || '' }}</text></view><text class="feed-title">{{ feed.title || '家族动态' }}</text><text class="feed-content">{{ feed.content || feed.feedContent }}</text><text class="feed-author">发布人:{{ feed.author || feed.creatorName || '族人' }}</text></view><view v-if="!feeds.length && !loadError" class="empty-copy">暂未发布家族动态</view></view>
<AppTabbar active="family" />
</view>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import AppTabbar from '@/components/AppTabbar.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const feeds = ref([])
const loadError = ref('')
const genealogyId = ref('')
const sections = [
{ type: 'article', name: '谱文', detail: '族史与家训' },
{ type: 'album', name: '相册', detail: '珍存旧影' },
{ type: 'ceremony', name: '祭祀', detail: '慎终追远' },
{ type: 'record', name: '族务', detail: '共同记事' }
]
onMounted(async () => {
genealogyId.value = genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value) { loadError.value = '请先从“我的家谱”选择一个家谱。'; return }
try { feeds.value = await appApi.getFeeds(genealogyId.value) } catch (error) { loadError.value = error.message || '家族动态加载失败。' }
})
const openSection = (type) => uni.navigateTo({ url: `/pages/content/list?type=${type}&genealogyId=${genealogyId.value}` })
const publishFeed = () => {
if (!genealogyId.value) { uni.showToast({ title: '请先选择家谱', icon: 'none' }); return }
uni.navigateTo({ url: `/pages/content/editor?type=feed&genealogyId=${genealogyId.value}` })
}
</script>
<style scoped lang="scss">
.family-page { padding: 42rpx 28rpx 178rpx; }
.family-top { display: flex; align-items: flex-start; justify-content: space-between; }
.top-kicker { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 5rpx; }
.page-name { display: block; margin-top: 10rpx; color: $ink; font-family: serif; font-size: 52rpx; font-weight: 700; letter-spacing: 5rpx; }
.publish { margin-top: 22rpx; color: $brand-red; font-size: 26rpx; }
.error-copy { margin-top: 24rpx; color: $brand-red; font-size: 24rpx; }
.content-rail { display: flex; gap: 12rpx; margin-top: 34rpx; overflow-x: auto; }
.rail-item { min-width: 150rpx; padding: 18rpx; border: 1rpx solid $gold-soft; background: #fffaf0; }
.rail-item text { display: block; color: $ink; font-size: 28rpx; font-weight: 700; }
.rail-item text + text { margin-top: 8rpx; color: $ink-muted; font-size: 20rpx; font-weight: 400; }
.feed-heading { display: flex; justify-content: space-between; align-items: center; margin-top: 38rpx; }
.feed-filter { color: $brand-red; font-size: 24rpx; }
.feed-list { display: flex; flex-direction: column; gap: 18rpx; margin-top: 20rpx; }
.feed-card { padding: 28rpx; }
.feed-meta { display: flex; justify-content: space-between; color: $gold; font-size: 21rpx; }
.feed-title { display: block; margin-top: 16rpx; color: $ink; font-size: 32rpx; font-weight: 700; }
.feed-content { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.65; }
.feed-author { display: block; margin-top: 16rpx; color: #9a8c78; font-size: 21rpx; }
.empty-copy { padding: 80rpx 0; color: $ink-muted; font-size: 26rpx; text-align: center; }
</style>
+67
View File
@@ -0,0 +1,67 @@
<template>
<view class="page-shell applications-page">
<PageHeader title="入谱审核" action="审核说明" @action="showHelp" />
<view class="audit-tip"><text>{{ loadError || '通过前请核实申请人的亲属关系;审核结果会通过通知告知对方。' }}</text></view>
<view class="application-list">
<view v-for="item in applications" :key="item.id" class="application-card paper-card">
<view class="application-top"><view><text class="applicant-name">{{ item.name }}</text><text class="applicant-phone">{{ item.phone }}</text></view><text class="applicant-time">{{ item.appliedAt }}</text></view>
<text class="relation-copy">{{ item.relation }}</text>
<view v-if="item.status === 'PENDING'" class="audit-actions"><button class="reject-button" @click="audit(item, false)">拒绝</button><button class="approve-button" @click="audit(item, true)">通过</button></view>
<text v-else class="audit-status">{{ item.status === 'APPROVED' ? '已通过' : '已拒绝' }}</text>
</view>
<view v-if="!applications.length && !loadError" class="empty-copy">暂无待审核申请</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const applications = ref([])
const genealogyId = ref('')
const loadError = ref('')
onLoad(async (query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value) { loadError.value = '未找到当前家谱,请从家谱总览进入。'; return }
genealogyContext.setCurrentGenealogyId(genealogyId.value)
try {
applications.value = await appApi.getPendingApplications(genealogyId.value)
} catch (error) {
loadError.value = error.message || '审核列表加载失败。'
}
})
const audit = async (item, approved) => {
try {
await appApi.auditApplication(genealogyId.value, item.id, approved)
item.status = approved ? 'APPROVED' : 'REJECTED'
uni.showToast({ title: approved ? '已通过申请' : '已拒绝申请', icon: 'none' })
} catch (error) {
uni.showToast({ title: error.message || '审核失败,请稍后重试', icon: 'none' })
}
}
const showHelp = () => uni.showModal({ title: '审核说明', content: '仅确认与本家谱存在真实亲属关系的申请,避免错误入谱。', showCancel: false })
</script>
<style scoped lang="scss">
.applications-page { padding-bottom: 36rpx; }
.audit-tip { margin: 26rpx 28rpx 0; padding: 24rpx; border-left: 5rpx solid $brand-red; background: #efe3cd; color: $ink-muted; font-size: 24rpx; line-height: 1.6; }
.application-list { display: flex; flex-direction: column; gap: 20rpx; margin: 24rpx 28rpx; }
.application-card { padding: 28rpx; }
.application-top { display: flex; justify-content: space-between; }
.applicant-name { color: $ink; font-size: 32rpx; font-weight: 700; }
.applicant-phone { margin-left: 16rpx; color: $ink-muted; font-size: 22rpx; }
.applicant-time { color: #a39583; font-size: 21rpx; }
.relation-copy { display: block; margin-top: 16rpx; color: $ink-muted; font-size: 25rpx; }
.audit-actions { display: flex; justify-content: flex-end; gap: 16rpx; margin-top: 24rpx; }
.audit-actions button { min-width: 128rpx; min-height: 64rpx; margin: 0; border-radius: 8rpx; font-size: 24rpx; }
.reject-button { border: 1rpx solid $gold-soft; background: #fffaf0; color: $ink-muted; }
.approve-button { border: 1rpx solid $brand-red; background: $brand-red; color: #fff9ed; }
.audit-status { display: block; margin-top: 24rpx; color: $brand-red; font-size: 24rpx; text-align: right; }
.empty-copy { padding: 80rpx 0; color: $ink-muted; font-size: 26rpx; text-align: center; }
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<view class="page-shell create-page">
<PageHeader title="创建家谱" />
<view class="form-paper">
<view class="section-title">立谱信息</view>
<text class="form-note">家谱建立后可继续完善名称与访问规则由创建者维护</text>
<view class="field-row">
<text>姓氏</text>
<input v-model="form.surname" maxlength="4" placeholder="如:汤" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text>谱名</text>
<input v-model="form.name" maxlength="24" placeholder="如:四川武胜汤氏族谱" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text>堂号</text>
<input v-model="form.hall" maxlength="12" placeholder="选填" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text>所在地</text>
<input v-model="form.location" maxlength="30" placeholder="请选择或输入" placeholder-class="placeholder" />
</view>
</view>
<view class="privacy-paper">
<view class="privacy-heading">
<text class="section-title">访问规则</text>
<text class="privacy-value">默认仅成员可见</text>
</view>
<text class="form-note">保护族人资料创建者可在家谱设置中开放搜索与申请加入</text>
</view>
<view class="bottom-action"><button class="primary-button" @click="submit">创建并录入首代</button></view>
</view>
</template>
<script setup>
import { reactive } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const form = reactive({ surname: '', name: '', hall: '', location: '', visibility: 'MEMBER_ONLY' })
const submit = async () => {
if (!form.surname.trim() || !form.name.trim()) {
uni.showToast({ title: '请填写姓氏和谱名', icon: 'none' })
return
}
const created = await appApi.createGenealogy({ ...form })
genealogyContext.setCurrentGenealogyId(created.id)
uni.redirectTo({ url: `/pages/lineage/first-person?genealogyId=${created.id}` })
}
</script>
<style scoped lang="scss">
.create-page { padding-bottom: 136rpx; }
.form-paper, .privacy-paper { margin: 28rpx; padding: 34rpx 30rpx; border: 1rpx solid $gold-soft; border-radius: 22rpx; background: $paper-white; }
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
.field-row { display: flex; align-items: center; min-height: 104rpx; border-bottom: 1rpx solid #eadfc9; }
.field-row:last-child { border-bottom: 0; }
.field-row text { width: 130rpx; color: $ink-muted; font-size: 28rpx; }
.field-row input { flex: 1; color: $ink; font-size: 29rpx; }
.placeholder { color: #b7aa97; }
.privacy-heading { display: flex; align-items: center; justify-content: space-between; }
.privacy-value { color: $brand-red; font-size: 24rpx; }
.bottom-action { position: fixed; right: 0; bottom: 0; left: 0; padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fffaf0; border-top: 1rpx solid $gold-soft; }
</style>
+83
View File
@@ -0,0 +1,83 @@
<template>
<view class="page-shell detail-page">
<PageHeader title="家谱总览" action="管理" @action="showSoon" />
<view v-if="genealogy" class="genealogy-hero">
<text class="hero-kicker">{{ genealogy.hall || '堂号待补' }} · {{ genealogy.location || '所在地待补' }}</text>
<text class="hero-title">{{ genealogy.name }}</text>
<text class="hero-motto">{{ genealogy.motto || '敦亲睦族,敬祖传家。' }}</text>
<view class="hero-stats"><text> {{ genealogy.memberCount || 0 }} </text><text>已激活 {{ genealogy.activeCount || 0 }} </text><text>{{ genealogy.visibility || '仅成员可见' }}</text></view>
</view>
<view v-else class="state-copy">{{ loadError || '正在载入家谱…' }}</view>
<template v-if="genealogy">
<view class="core-actions paper-card">
<view class="section-title">家谱核心</view>
<view class="action-grid">
<view class="action-cell primary-cell" @click="toTree"><text>世系树</text><text>查看家脉关系</text></view>
<view class="action-cell" @click="toFirstPerson"><text>录入族人</text><text>从首代开始完善</text></view>
<view class="action-cell" @click="showSoon"><text>字辈谱</text><text>传承行辈次序</text></view>
<view class="action-cell" @click="toApplications"><text>入谱审核</text><text>处理加入申请</text></view>
</view>
</view>
<view class="family-summary"><text class="section-title">家族近况</text><view class="summary-row" @click="toFamily"><text>进入家族圈查看动态</text><text>查看</text></view></view>
<view class="invite-block"><text class="invite-title">邀亲人共建家谱</text><text class="invite-copy">族人申请加入后由创建者审核确认</text><button class="primary-button" @click="showSoon">邀请亲人</button></view>
</template>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const genealogy = ref(null)
const genealogyId = ref('')
const loadError = ref('')
const loadGenealogy = async (id) => {
genealogyId.value = id || genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value) {
loadError.value = '未找到当前家谱,请从“我的家谱”重新进入。'
return
}
genealogyContext.setCurrentGenealogyId(genealogyId.value)
try {
genealogy.value = await appApi.getOverview(genealogyId.value)
if (!genealogy.value) loadError.value = '家谱不存在或无权访问。'
} catch (error) {
loadError.value = error.message || '家谱加载失败。'
}
}
onLoad((query) => loadGenealogy(query.id))
const toTree = () => uni.navigateTo({ url: `/pages/tree/index?genealogyId=${genealogyId.value}` })
const toFirstPerson = () => uni.navigateTo({ url: `/pages/lineage/first-person?genealogyId=${genealogyId.value}` })
const toFamily = () => uni.reLaunch({ url: '/pages/family/index' })
const toApplications = () => uni.navigateTo({ url: `/pages/genealogy/applications?genealogyId=${genealogyId.value}` })
const showSoon = () => uni.showToast({ title: '该模块将在后续阶段接入', icon: 'none' })
</script>
<style scoped lang="scss">
.detail-page { padding-bottom: 48rpx; }
.genealogy-hero { padding: 48rpx 34rpx 38rpx; background: $navy; color: #fff8ec; }
.hero-kicker { display: block; color: #e6c57f; font-size: 22rpx; letter-spacing: 4rpx; }
.hero-title { display: block; margin-top: 20rpx; font-family: serif; font-size: 52rpx; font-weight: 700; letter-spacing: 4rpx; }
.hero-motto { display: block; margin-top: 16rpx; color: #eadcbf; font-size: 25rpx; }
.hero-stats { display: flex; flex-wrap: wrap; gap: 12rpx 20rpx; margin-top: 28rpx; color: #dac797; font-size: 22rpx; }
.state-copy { padding: 80rpx 40rpx; color: $ink-muted; font-size: 27rpx; text-align: center; }
.core-actions { margin: -18rpx 28rpx 0; padding: 30rpx; }
.action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; margin-top: 26rpx; }
.action-cell { min-height: 122rpx; padding: 20rpx; box-sizing: border-box; border: 1rpx solid #e7d9be; background: #fbf5e9; }
.action-cell text { display: block; color: $ink; font-size: 28rpx; font-weight: 700; }
.action-cell text + text { margin-top: 10rpx; color: $ink-muted; font-size: 21rpx; font-weight: 400; }
.primary-cell { border-color: $brand-red; background: #f8ede3; }
.primary-cell text:first-child { color: $brand-red; }
.family-summary { margin: 38rpx 28rpx 0; }
.summary-row { display: flex; justify-content: space-between; margin-top: 14rpx; padding: 24rpx 6rpx; border-bottom: 1rpx solid #dfd1b6; color: $ink-muted; font-size: 25rpx; }
.summary-row text:last-child { color: $brand-red; }
.invite-block { margin: 42rpx 28rpx 0; padding: 32rpx; border: 1rpx solid $gold; background: #efe0c0; }
.invite-title { display: block; color: $brand-red; font-family: serif; font-size: 37rpx; font-weight: 700; }
.invite-copy { display: block; margin: 14rpx 0 24rpx; color: $ink-muted; font-size: 23rpx; }
</style>
+573
View File
@@ -0,0 +1,573 @@
<template>
<view class="page-shell genealogy-index">
<image
class="page-paper-texture"
src="/static/assets/backgrounds/paper-rice-texture-v2.jpg"
mode="scaleToFill"
/>
<image
class="page-footer-landscape"
src="/static/assets/backgrounds/footer-mountain-bamboo.png"
mode="scaleToFill"
/>
<PageHeader
root
title="我的家谱"
:unread-count="unreadCount"
@notice="toNotifications"
/>
<view class="genealogy-content">
<view v-if="isLoading" class="state-panel state-panel--loading">
<view class="loading-seal"></view>
<text class="state-title">正在整理家谱</text>
<text class="state-copy">请稍候家族记忆正在归卷</text>
</view>
<template v-else-if="hasGenealogies">
<view class="current-slip" @click="openGenealogy(currentGenealogy)">
<image
class="current-frame"
src="/static/assets/backgrounds/genealogy-current-slip-frame.png"
mode="scaleToFill"
/>
<view class="current-seal">
<image
class="current-seal-frame"
src="/static/assets/icons/genealogy/seal-current-frame-v1.png"
mode="scaleToFill"
/>
<text class="current-seal-title">家谱</text>
</view>
<view class="current-copy">
<text class="current-name">{{ currentGenealogy.name }}</text>
<view class="current-info-divider"></view>
<view class="current-meta">
<view class="current-meta-item">
<image
class="current-meta-icon"
src="/static/assets/icons/common/location-v1.png"
mode="aspectFit"
/>
<text class="current-meta-item-text">{{
currentGenealogy.location
}}</text>
</view>
<view class="current-meta-item">
<image
class="current-meta-icon"
src="/static/assets/icons/common/member-meta-v1.png"
mode="aspectFit"
/>
<text class="current-meta-item-text"
>{{ currentGenealogy.memberCount }} 位成员</text
>
</view>
<view class="current-meta-item">
<image
class="current-meta-icon"
src="/static/assets/icons/common/admin-v1.png"
mode="aspectFit"
/>
<text class="current-meta-item-text">管理员</text>
</view>
</view>
</view>
</view>
<view class="shortcut-grid">
<view
v-for="item in shortcuts"
:key="item.key"
class="shortcut-item"
@click="openShortcut(item.key)"
>
<image class="shortcut-icon" :src="item.icon" mode="aspectFit" />
<text class="shortcut-label">{{ item.label }}</text>
</view>
</view>
<image
class="section-divider"
src="/static/assets/backgrounds/genealogy-section-divider-v2.png"
mode="scaleToFill"
/>
<view class="genealogy-lower">
<view v-if="createdGenealogies.length" class="list-section">
<view class="section-heading"><text>我创建的</text></view>
<GenealogyCard
v-for="item in createdGenealogies"
:key="item.id"
:genealogy="item"
role="管理员"
:selected="item.id === currentGenealogy.id"
@select="openGenealogy"
/>
</view>
<view v-if="joinedGenealogies.length" class="list-section">
<view class="section-heading"><text>我加入的</text></view>
<GenealogyCard
v-for="item in joinedGenealogies"
:key="item.id"
:genealogy="item"
role="成员"
@select="openGenealogy"
/>
</view>
<view class="create-action" @click="createGenealogy">
<image
class="create-cloud"
src="/static/assets/icons/action/create-cloud-v1.png"
mode="aspectFit"
/>
<image
class="create-icon"
src="/static/assets/icons/action/add-v2.png"
mode="aspectFit"
/>
<text>新建家谱</text>
<image
class="create-cloud create-cloud--right"
src="/static/assets/icons/action/create-cloud-v1.png"
mode="aspectFit"
/>
</view>
</view>
</template>
<view v-else class="state-panel state-panel--empty">
<view class="empty-scroll"><text>待立家谱</text></view>
<text class="state-title">从第一位家人开始</text>
<text class="state-copy">为家族留下可传承的名字故事与记忆</text>
<view class="empty-primary" @click="createGenealogy">
<image
class="create-icon"
src="/static/assets/icons/action/add-v2.png"
mode="aspectFit"
/>
<text>新建家谱</text>
</view>
<view class="empty-secondary" @click="applyToJoin"
><text>搜索并申请加入</text></view
>
</view>
</view>
<AppTabbar active="genealogy" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import AppTabbar from "@/components/AppTabbar.vue";
import GenealogyCard from "@/components/GenealogyCard.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogies, notifications } from "@/data/mock.js";
const isLoading = ref(false);
const list = ref(genealogies);
const unreadCount = computed(
() => notifications.filter((item) => item.unread).length,
);
const hasGenealogies = computed(() => list.value.length > 0);
const createdGenealogies = computed(() =>
list.value.filter((item, index) => index === 0),
);
const joinedGenealogies = computed(() =>
list.value.filter((item, index) => index > 0),
);
const currentGenealogy = computed(() => list.value[0]);
const shortcuts = [
{
key: "tree",
label: "世系图",
icon: "/static/assets/icons/genealogy/tree-v2.png",
},
{
key: "members",
label: "成员",
icon: "/static/assets/icons/genealogy/members-v2.png",
},
{
key: "poem",
label: "字辈诗",
icon: "/static/assets/icons/genealogy/generation-poem-v2.png",
},
{
key: "applications",
label: "申请审核",
icon: "/static/assets/icons/genealogy/application-v2.png",
},
];
const openGenealogy = (genealogy) =>
uni.navigateTo({ url: `/pages/genealogy/detail?id=${genealogy.id}` });
const createGenealogy = () =>
uni.navigateTo({ url: "/pages/genealogy/create" });
const applyToJoin = () => uni.navigateTo({ url: "/pages/genealogy/search" });
const toNotifications = () =>
uni.navigateTo({ url: "/pages/notification/index" });
const openShortcut = (key) => {
const paths = {
tree: "/pages/tree/index",
members: `/pages/genealogy/detail?id=${currentGenealogy.value.id}`,
poem: "/pages/content/list?type=poem",
applications: "/pages/genealogy/applications",
};
uni.navigateTo({ url: paths[key] });
};
</script>
<style scoped lang="scss">
.genealogy-index {
position: relative;
min-height: 100vh;
overflow: hidden;
background: #f9f6ef;
}
.page-paper-texture {
position: absolute;
top: 184rpx;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
opacity: 0.72;
pointer-events: none;
}
.page-footer-landscape {
position: absolute;
right: 0;
bottom: 128rpx;
left: 0;
z-index: 1;
width: 100%;
height: 360rpx;
opacity: 0.48;
pointer-events: none;
}
.genealogy-content {
position: relative;
z-index: 2;
padding: 28rpx 48rpx 194rpx;
}
.current-slip {
position: relative;
display: flex;
min-height: 272rpx;
align-items: center;
padding: 34rpx 30rpx;
box-sizing: border-box;
background: transparent;
}
.current-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.current-seal {
position: relative;
z-index: 1;
display: flex;
width: 80rpx;
height: 136rpx;
flex: 0 0 auto;
align-items: center;
justify-content: center;
margin-right: 26rpx;
color: #fff7e7;
}
.current-seal-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
}
.current-seal-title {
position: relative;
z-index: 1;
color: #fff7e7;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
font-weight: 700;
letter-spacing: 2rpx;
writing-mode: vertical-rl;
}
.current-copy {
position: relative;
z-index: 1;
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.current-name {
overflow: hidden;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 50rpx;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-info-divider {
width: 100%;
height: 1rpx;
margin: 20rpx 0 18rpx;
background: rgba(181, 138, 75, 0.48);
}
.current-meta {
display: flex;
flex-wrap: nowrap;
margin-top: 0;
color: $ink-muted;
font-size: 25rpx;
line-height: 1.4;
}
.current-meta-item {
display: flex;
align-items: center;
margin: 0 22rpx 8rpx 0;
white-space: nowrap;
.current-meta-item-text{
white-space: nowrap;
}
}
.current-meta-icon {
width: 32rpx;
height: 32rpx;
margin-right: 8rpx;
}
.shortcut-grid {
display: flex;
min-height: 208rpx;
align-items: center;
margin-top: 36rpx;
padding: 14rpx 0;
}
.shortcut-item {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: center;
}
.shortcut-icon {
width: 82rpx;
height: 82rpx;
}
.shortcut-label {
overflow: hidden;
width: 100%;
margin-top: 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-divider {
display: block;
width: 100%;
height: 30rpx;
margin: 10rpx 0 2rpx;
}
.genealogy-lower {
padding: 18rpx 0 44rpx;
}
.list-section {
margin-top: 22rpx;
}
.section-heading {
display: flex;
min-height: 64rpx;
align-items: center;
margin-bottom: 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 34rpx;
font-weight: 700;
}
.section-heading::before {
width: 8rpx;
height: 34rpx;
margin-right: 14rpx;
border-radius: 8rpx;
background: $brand-red;
content: "";
}
.list-section + .list-section {
margin-top: 26rpx;
}
.create-action,
.empty-primary,
.empty-secondary {
display: flex;
min-height: 96rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
font-size: 30rpx;
}
.create-action {
width: 420rpx;
margin: 42rpx auto 0;
border: 2rpx solid $brand-red;
border-radius: 48rpx;
background: rgba(255, 249, 238, 0.66);
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-weight: 700;
letter-spacing: 3rpx;
}
.empty-primary {
width: 360rpx;
margin: 32rpx auto 0;
border: 2rpx solid $brand-red;
border-radius: 48rpx;
background: rgba(255, 249, 238, 0.94);
color: $brand-red;
font-family: serif;
font-weight: 700;
letter-spacing: 3rpx;
}
.create-icon {
width: 38rpx;
height: 38rpx;
margin-right: 12rpx;
}
.create-action .create-icon {
width: 44rpx;
height: 44rpx;
margin: 0 12rpx;
}
.create-cloud {
width: 64rpx;
height: 32rpx;
}
.create-cloud--right {
transform: scaleX(-1);
}
.state-panel {
display: flex;
min-height: 540rpx;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 42rpx;
box-sizing: border-box;
border: 2rpx solid $gold-soft;
border-radius: 18rpx;
background: rgba(255, 249, 238, 0.76);
text-align: center;
}
.state-title {
margin-top: 24rpx;
color: $ink;
font-family: serif;
font-size: 38rpx;
font-weight: 700;
}
.state-copy {
margin-top: 14rpx;
color: $ink-muted;
font-size: 25rpx;
line-height: 1.7;
}
.loading-seal,
.empty-scroll {
border: 2rpx solid $gold;
}
.loading-seal {
width: 92rpx;
height: 92rpx;
border-radius: 50%;
}
.empty-scroll {
display: flex;
width: 154rpx;
height: 196rpx;
align-items: center;
justify-content: center;
color: $gold;
font-family: serif;
font-size: 33rpx;
writing-mode: vertical-rl;
}
.empty-secondary {
margin-top: 16rpx;
color: $ink-muted;
font-size: 27rpx;
}
@media screen and (max-width: 340px) {
.genealogy-content {
padding-right: 24rpx;
padding-left: 24rpx;
}
.current-name {
font-size: 40rpx;
}
.shortcut-label {
font-size: 23rpx;
}
.current-meta-item {
margin-right: 12rpx;
}
.current-meta-icon {
width: 28rpx;
height: 28rpx;
}
.create-action {
width: 360rpx;
}
.create-cloud {
width: 48rpx;
}
}
</style>
+46
View File
@@ -0,0 +1,46 @@
<template>
<view class="page-shell search-page">
<PageHeader title="查找家谱" />
<view class="search-panel">
<input v-model="keyword" class="search-input" placeholder="输入姓氏、谱名或地区" placeholder-class="placeholder" @confirm="search" />
<text class="search-button" @click="search">搜索</text>
</view>
<text class="search-tip">仅展示已开放申请加入的家谱</text>
<view class="result-list">
<GenealogyCard v-for="item in results" :key="item.id" :genealogy="item" @select="openApply" />
<view v-if="searched && !results.length" class="empty-result"><text>暂未找到公开家谱</text><text>可联系家谱创建者开通公开申请</text></view>
</view>
</view>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import GenealogyCard from '@/components/GenealogyCard.vue'
import { appApi } from '@/utils/api.js'
const keyword = ref('')
const allResults = ref([])
const results = ref([])
const searched = ref(false)
onMounted(async () => { allResults.value = await appApi.getPublicGenealogies(); results.value = allResults.value })
const search = () => {
const value = keyword.value.trim()
results.value = value ? allResults.value.filter((item) => `${item.name}${item.surname}${item.location}`.includes(value)) : allResults.value
searched.value = true
}
const openApply = (genealogy) => uni.showModal({ title: '申请加入', content: `确认申请加入“${genealogy.name}”吗?`, success: async ({ confirm }) => { if (confirm) { await appApi.applyToJoin(genealogy.id, { message: '希望加入家谱并完善本人资料' }); uni.showToast({ title: '申请已提交,等待审核', icon: 'none' }) } } })
</script>
<style scoped lang="scss">
.search-page { padding-bottom: 36rpx; }
.search-panel { display: flex; align-items: center; gap: 18rpx; margin: 28rpx; padding: 0 22rpx; border: 1rpx solid $gold; background: #fffaf0; }
.search-input { flex: 1; height: 82rpx; color: $ink; font-size: 27rpx; }
.placeholder { color: #b6aa99; }
.search-button { color: $brand-red; font-size: 27rpx; font-weight: 700; }
.search-tip { display: block; margin: -8rpx 28rpx 22rpx; color: $ink-muted; font-size: 22rpx; }
.result-list { display: flex; flex-direction: column; gap: 18rpx; margin: 0 28rpx; }
.empty-result { display: flex; flex-direction: column; align-items: center; gap: 14rpx; padding: 90rpx 20rpx; color: $ink-muted; font-size: 26rpx; }
.empty-result text + text { color: #a69783; font-size: 22rpx; }
</style>
+60
View File
@@ -0,0 +1,60 @@
<template>
<view class="page-shell first-person-page">
<PageHeader title="录入首代人物" />
<view class="form-paper">
<view class="section-title">家谱从这一位开始</view>
<text class="form-note">可先录入姓名世代和简要生平其他资料后续随时补充</text>
<view class="field-row"><text>姓名</text><input v-model="form.personName" maxlength="20" placeholder="请输入首代姓名" placeholder-class="placeholder" /></view>
<view class="field-row"><text>世代</text><text class="fixed-value">第一世</text></view>
<view class="field-row"><text>出生日期</text><input v-model="form.birthDate" placeholder="如:1940年" placeholder-class="placeholder" /></view>
<view class="intro-field"><text>生平简述</text><textarea v-model="form.introduction" maxlength="200" placeholder="可选,记录家训、迁徙或重要经历" placeholder-class="placeholder" /></view>
</view>
<view class="bottom-action"><button class="primary-button" :loading="isSubmitting" :disabled="isSubmitting" @click="submit">保存并查看世系树</button></view>
</view>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const genealogyId = ref('')
const isSubmitting = ref(false)
const form = reactive({ personName: '', generationNo: 1, generationName: '一世', sex: '0', birthDate: '', introduction: '' })
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId()
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value)
})
const submit = async () => {
if (!genealogyId.value) { uni.showToast({ title: '未找到当前家谱', icon: 'none' }); return }
if (!form.personName.trim()) { uni.showToast({ title: '请填写首代姓名', icon: 'none' }); return }
isSubmitting.value = true
try {
const person = await appApi.createPerson(genealogyId.value, { ...form, personName: form.personName.trim() })
uni.redirectTo({ url: `/pages/tree/index?genealogyId=${genealogyId.value}&selectedId=${person.id}` })
} catch (error) {
uni.showToast({ title: error.message || '保存失败,请稍后重试', icon: 'none' })
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped lang="scss">
.first-person-page { padding-bottom: 136rpx; }
.form-paper { margin: 28rpx; padding: 34rpx 30rpx; border: 1rpx solid $gold-soft; border-radius: 22rpx; background: $paper-white; }
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
.field-row { display: flex; align-items: center; min-height: 104rpx; border-bottom: 1rpx solid #eadfc9; }
.field-row text { width: 150rpx; color: $ink-muted; font-size: 28rpx; }
.field-row input { flex: 1; color: $ink; font-size: 29rpx; }
.fixed-value { color: $brand-red !important; }
.intro-field { padding-top: 30rpx; }
.intro-field > text { display: block; color: $ink-muted; font-size: 28rpx; }
.intro-field textarea { width: 100%; height: 180rpx; margin-top: 18rpx; color: $ink; font-size: 27rpx; line-height: 1.6; }
.placeholder { color: #b7aa97; }
.bottom-action { position: fixed; right: 0; bottom: 0; left: 0; padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fffaf0; border-top: 1rpx solid $gold-soft; }
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<view class="page-shell member-page">
<PageHeader title="成员档案" action="编辑" @action="showSoon" />
<view v-if="member" class="member-hero"><view class="member-seal">{{ member.name.slice(0, 1) }}</view><view><text class="member-name">{{ member.name }}</text><text class="member-role"> {{ member.generation }} · {{ member.relation }} · 家谱成员</text></view></view>
<view v-else class="state-copy">{{ loadError || '正在载入成员档案…' }}</view>
<template v-if="member">
<view class="profile-card paper-card"><text class="section-title">基本资料</text><view v-for="item in details" :key="item.label" class="info-row"><text>{{ item.label }}</text><text>{{ item.value }}</text></view></view>
<view class="profile-card paper-card"><text class="section-title">亲属关系</text><text class="bio">亲属关系将在编辑功能接入后完整维护</text></view>
<view v-if="member.introduction" class="profile-card paper-card"><text class="section-title">生平事迹</text><text class="bio">{{ member.introduction }}</text></view>
</template>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const member = ref(null)
const loadError = ref('')
const genealogyId = ref('')
const details = computed(() => member.value ? [
{ label: '字辈', value: member.value.generationName || `${member.value.generation}` },
{ label: '出生日期', value: member.value.birthDate || '待补' },
{ label: '生卒信息', value: member.value.years || '待补' },
{ label: '所属支系', value: member.value.branch || '主支' }
] : [])
onLoad(async (query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value || !query.personId) { loadError.value = '成员参数不完整,请从世系树进入。'; return }
genealogyContext.setCurrentGenealogyId(genealogyId.value)
try {
member.value = await appApi.getPerson(genealogyId.value, query.personId)
if (!member.value) loadError.value = '未找到该成员。'
} catch (error) {
loadError.value = error.message || '成员档案加载失败。'
}
})
const showSoon = () => uni.showToast({ title: '资料编辑将在下一阶段接入', icon: 'none' })
</script>
<style scoped lang="scss">
.member-page { padding-bottom: 36rpx; }
.member-hero { display: flex; align-items: center; gap: 24rpx; padding: 38rpx 34rpx; background: $navy; }
.member-seal { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; border: 3rpx solid #e0bd77; border-radius: 50%; color: #f4daa1; font-family: serif; font-size: 46rpx; }
.member-name { display: block; color: #fff8ec; font-family: serif; font-size: 46rpx; font-weight: 700; }
.member-role { display: block; margin-top: 10rpx; color: #e0c993; font-size: 23rpx; }
.state-copy { padding: 80rpx 40rpx; color: $ink-muted; font-size: 27rpx; text-align: center; }
.profile-card { margin: 26rpx 28rpx 0; padding: 30rpx; }
.info-row { display: flex; justify-content: space-between; padding: 25rpx 0; border-bottom: 1rpx solid #eadfc9; color: $ink-muted; font-size: 27rpx; }
.info-row text:last-child { color: $ink; }
.info-row:last-child { border-bottom: 0; }
.bio { display: block; margin-top: 22rpx; color: $ink-muted; font-size: 27rpx; line-height: 1.9; }
</style>
+54
View File
@@ -0,0 +1,54 @@
<template>
<view class="page-shell notification-page">
<PageHeader title="消息通知" action="全部已读" @action="readAll" />
<view class="notice-intro"><text>{{ loadError || '与家谱相关的提醒都会留在这里。' }}</text></view>
<view class="notice-list"><view v-for="item in list" :key="item.id" class="notice-row" :class="{ unread: item.unread }" @click="read(item)"><view class="notice-dot"></view><view class="notice-copy"><text class="notice-title">{{ item.title }}</text><text class="notice-content">{{ item.content }}</text></view><text class="notice-time">{{ item.time }}</text></view></view>
</view>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
const list = ref([])
const loadError = ref('')
onMounted(async () => {
try {
list.value = await appApi.getNotifications()
} catch (error) {
loadError.value = error.message || '通知加载失败。'
}
})
const read = async (item) => {
if (!item.unread) return
try {
await appApi.markNotificationRead(item.id)
item.unread = false
} catch (error) {
uni.showToast({ title: error.message || '标记已读失败', icon: 'none' })
}
}
const readAll = async () => {
try {
await appApi.markAllNotificationsRead()
list.value.forEach((item) => { item.unread = false })
uni.showToast({ title: '已全部标记为已读', icon: 'none' })
} catch (error) {
uni.showToast({ title: error.message || '操作失败,请稍后重试', icon: 'none' })
}
}
</script>
<style scoped lang="scss">
.notification-page { padding-bottom: 36rpx; }
.notice-intro { padding: 28rpx 30rpx; background: #efe3cd; color: $ink-muted; font-size: 24rpx; }
.notice-list { margin: 0 28rpx; }
.notice-row { display: flex; align-items: flex-start; gap: 16rpx; padding: 28rpx 0; border-bottom: 1rpx solid #e4d8c0; }
.notice-dot { width: 14rpx; height: 14rpx; flex: 0 0 auto; margin-top: 13rpx; border-radius: 50%; background: transparent; }
.notice-row.unread .notice-dot { background: $brand-red; }
.notice-copy { min-width: 0; flex: 1; }
.notice-title { display: block; color: $ink; font-size: 30rpx; font-weight: 700; }
.notice-content { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
.notice-time { flex: 0 0 auto; color: #a19482; font-size: 20rpx; }
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<view class="page-shell profile-page">
<view class="profile-hero"><view class="profile-seal">{{ profile.name ? profile.name.slice(0, 1) : '谱' }}</view><view class="profile-copy"><text class="profile-name">{{ profile.name || '家谱用户' }}</text><text class="profile-meta">{{ profile.phone || '资料加载中' }} · {{ profile.role || '家谱成员' }}</text></view><text class="profile-edit" @click="showSoon">资料</text></view>
<text v-if="loadError" class="error-copy">{{ loadError }}</text>
<view class="todo-card paper-card" @click="toNotifications"><view><text class="todo-title">待你处理</text><text class="todo-copy">家谱相关提醒与审核通知</text></view><text class="todo-count">{{ unreadCount }}</text></view>
<view class="menu-group paper-card"><view v-for="item in accountItems" :key="item" class="menu-row" @click="showSoon"><text>{{ item }}</text><text>查看</text></view></view>
<view class="menu-group paper-card"><view v-for="item in serviceItems" :key="item" class="menu-row" @click="showSoon"><text>{{ item }}</text><text>查看</text></view></view>
<AppTabbar active="profile" />
</view>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import AppTabbar from '@/components/AppTabbar.vue'
import { appApi } from '@/utils/api.js'
import { notifications } from '@/data/mock.js'
const profile = ref({})
const loadError = ref('')
const unreadCount = computed(() => notifications.filter((item) => item.unread).length)
const accountItems = ['我的资料', '我的加入申请', '账号与安全']
const serviceItems = ['帮助中心', '意见反馈', 'VIP 服务']
onMounted(async () => {
try { profile.value = await appApi.getProfile() } catch (error) { loadError.value = error.message || '个人资料加载失败。' }
})
const toNotifications = () => uni.navigateTo({ url: '/pages/notification/index' })
const showSoon = () => uni.showToast({ title: '该服务将按计划逐步开放', icon: 'none' })
</script>
<style scoped lang="scss">
.profile-page { padding-bottom: 178rpx; }
.profile-hero { display: flex; align-items: center; gap: 22rpx; padding: 48rpx 30rpx; background: $navy; }
.profile-seal { display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; border: 3rpx solid #e0bd77; border-radius: 50%; color: #f4daa1; font-family: serif; font-size: 43rpx; }
.profile-copy { min-width: 0; flex: 1; }
.profile-name { display: block; color: #fff8ec; font-family: serif; font-size: 43rpx; font-weight: 700; }
.profile-meta { display: block; margin-top: 10rpx; color: #dfc590; font-size: 21rpx; }
.profile-edit { color: #f4dca8; font-size: 24rpx; }
.error-copy { display: block; margin: 18rpx 28rpx 0; color: $brand-red; font-size: 23rpx; }
.todo-card { display: flex; align-items: center; justify-content: space-between; margin: -14rpx 28rpx 0; padding: 28rpx; background: #fff8ea; }
.todo-title { display: block; color: $ink; font-size: 30rpx; font-weight: 700; }
.todo-copy { display: block; margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; }
.todo-count { display: flex; width: 52rpx; height: 52rpx; align-items: center; justify-content: center; border-radius: 50%; background: $brand-red; color: #fff8ec; font-size: 26rpx; }
.menu-group { margin: 28rpx 28rpx 0; padding: 0 28rpx; }
.menu-row { display: flex; align-items: center; justify-content: space-between; min-height: 102rpx; border-bottom: 1rpx solid #eadfc9; color: $ink; font-size: 29rpx; }
.menu-row:last-child { border-bottom: 0; }
.menu-row text:last-child { color: $brand-red; font-size: 23rpx; }
</style>
+62
View File
@@ -0,0 +1,62 @@
<template>
<view class="tree-page">
<PageHeader title="世系树" action="树形视图" />
<view class="tree-toolbar"><view class="branch-select">当前家谱世系</view><view class="tree-tools"><text @click="showSoon">查找</text><text @click="showSoon">编辑</text></view></view>
<scroll-view class="tree-scroll" scroll-x scroll-y>
<view class="tree-canvas">
<view v-if="!members.length" class="tree-empty">{{ loadError || '暂未录入族人' }}</view>
<view v-for="member in members" :key="member.id" class="member-node" :class="{ selected: selected && selected.id === member.id }" :style="nodeStyle(member)" @click="selected = member"><text class="node-name">{{ member.name }}</text><text class="node-relation">{{ member.relation }}</text><text class="node-years">{{ member.years }}</text></view>
</view>
</scroll-view>
<view v-if="selected" class="member-sheet"><view><text class="sheet-name">{{ selected.name }}</text><text class="sheet-meta">第 {{ selected.generation }} 世 · {{ selected.relation }} · {{ selected.branch }}</text></view><view class="sheet-actions"><text @click="toMember">查看资料</text><text @click="showSoon">添加亲属</text></view></view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const members = ref([])
const selected = ref(null)
const genealogyId = ref('')
const loadError = ref('')
const loadTree = async (query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value) { loadError.value = '未找到当前家谱。'; return }
genealogyContext.setCurrentGenealogyId(genealogyId.value)
try {
members.value = await appApi.getTree(genealogyId.value)
selected.value = members.value.find((item) => String(item.id) === String(query.selectedId)) || members.value[0] || null
} catch (error) {
loadError.value = error.message || '世系树加载失败。'
}
}
onLoad(loadTree)
const nodeStyle = (member) => ({ left: `${member.x}%`, top: `${member.y}%` })
const toMember = () => uni.navigateTo({ url: `/pages/member/detail?genealogyId=${genealogyId.value}&personId=${selected.value.id}` })
const showSoon = () => uni.showToast({ title: '亲属关系编辑将在下一阶段接入', icon: 'none' })
</script>
<style scoped lang="scss">
.tree-page { height: 100vh; overflow: hidden; background: $paper; }
.tree-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 28rpx; border-bottom: 1rpx solid $gold-soft; background: #fffaf0; }
.branch-select { padding: 12rpx 18rpx; border: 1rpx solid $gold; color: $ink; font-size: 25rpx; }
.tree-tools { display: flex; gap: 24rpx; color: $brand-red; font-size: 26rpx; }
.tree-scroll { height: calc(100vh - 382rpx); white-space: nowrap; }
.tree-canvas { position: relative; width: 1180rpx; height: 1120rpx; background: $paper; }
.tree-empty { padding: 240rpx 0; color: $ink-muted; font-size: 28rpx; text-align: center; }
.member-node { position: absolute; width: 150rpx; min-height: 148rpx; padding: 20rpx 12rpx; box-sizing: border-box; border: 2rpx solid $gold-soft; background: #fffaf0; transform: translateX(-50%); white-space: normal; text-align: center; }
.member-node.selected { border-color: $brand-red; box-shadow: 0 0 0 6rpx #f2dfcf; }
.node-name { display: block; color: $ink; font-family: serif; font-size: 32rpx; font-weight: 700; }
.node-relation { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
.node-years { display: block; margin-top: 8rpx; color: $ink-muted; font-size: 20rpx; }
.member-sheet { position: fixed; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: space-between; padding: 26rpx 28rpx calc(26rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid $gold; background: $navy; color: #fff8ec; }
.sheet-name { display: block; font-family: serif; font-size: 36rpx; font-weight: 700; }
.sheet-meta { display: block; margin-top: 8rpx; color: #dfc590; font-size: 21rpx; }
.sheet-actions { display: flex; flex-direction: column; gap: 12rpx; color: #f3dba7; font-size: 24rpx; text-align: right; }
</style>