Initial project commit

This commit is contained in:
2026-05-09 16:40:29 +08:00
commit 02b0259a9e
267 changed files with 54891 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { getFavorites } from "@/api/favorite";
import { extractList } from "@/utils/request";
import { checkLogin } from "@/utils/auth";
import SpotCard from "@/components/spot-card/spot-card.vue";
onShow(() => { checkLogin(); });
const favorites = ref([]);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
const loading = ref(false);
const fetchFavorites = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const res = await getFavorites({ page: page.value, page_size: pageSize });
const list = extractList(res);
if (reset) {
favorites.value = list;
} else {
favorites.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const goDetail = (item) => {
const id = item.spot_id || item.spot?.id || item.id;
if (id) {
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
}
};
onPullDownRefresh(async () => {
await fetchFavorites(true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
fetchFavorites();
});
fetchFavorites(true);
</script>
<template>
<view class="favorites-page">
<view class="list">
<SpotCard
v-for="item in favorites"
:key="item.id"
:spot="item.spot || item"
@click="goDetail(item)"
/>
<view v-if="loading" class="status-tip">
<text>加载中...</text>
</view>
<view v-else-if="!hasMore && favorites.length > 0" class="status-tip">
<text>没有更多了</text>
</view>
<view v-else-if="!loading && favorites.length === 0" class="empty-state">
<uni-icons type="heart-filled" size="48" color="#6366f1" class="empty-icon" />
<text class="empty-title">暂无收藏</text>
<text class="empty-desc">去发现页浏览并收藏喜欢的取景地吧</text>
</view>
</view>
</view>
</template>
<style scoped>
.favorites-page {
min-height: 100vh;
background: #f5f6fa;
}
.list {
padding: 24rpx 32rpx;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 0;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94a3b8;
}
</style>
+403
View File
@@ -0,0 +1,403 @@
<script setup>
import { ref, computed } from "vue";
import { useUserStore } from "@/store/user";
import { resolveImageUrl } from "@/utils/image";
import { getMyStats } from "@/api/user";
import { getUnreadCount } from "@/api/notification";
const userStore = useUserStore();
const isLoggedIn = computed(() => userStore.isLoggedIn);
const user = computed(() => userStore.userInfo);
const stats = ref({ spot_count: 0, approved_count: 0, favorite_count: 0, rating_received: 0 });
const unreadCount = ref(0);
const goLogin = () => {
uni.navigateTo({ url: "/pages/login/index" });
};
const handleLogout = () => {
uni.showModal({
title: "提示",
content: "确定要退出登录吗?",
success: (res) => {
if (res.confirm) {
userStore.logout();
}
},
});
};
const menuItems = [
{ label: "编辑资料", icon: "gear-filled", action: "profile" },
{ label: "我的收藏", icon: "heart-filled", action: "favorites" },
{ label: "我的投稿", icon: "location-filled", action: "my-spots" },
{ label: "约拍/活动", icon: "calendar-filled", action: "activity-hub" },
{ label: "会员中心", icon: "vip-filled", action: "membership" },
{ label: "积分概览", icon: "star-filled", action: "points" },
{ label: "消息通知", icon: "chat-filled", action: "notifications" },
{ label: "设置", icon: "gear", action: "settings" },
];
const handleMenu = (action) => {
switch (action) {
case "profile":
uni.navigateTo({ url: "/pages/mine/profile" });
break;
case "favorites":
uni.navigateTo({ url: "/pages/mine/favorites" });
break;
case "my-spots":
uni.navigateTo({ url: "/pages/mine/my-spots" });
break;
case "points":
uni.navigateTo({ url: "/pages/mine/points" });
break;
case "activity-hub":
uni.switchTab({ url: "/pages/activity/index" });
break;
case "membership":
uni.navigateTo({ url: "/pages/mine/membership" });
break;
case "notifications":
uni.switchTab({ url: "/pages/mine/notifications" });
break;
case "settings":
uni.navigateTo({ url: "/pages/mine/settings" });
break;
}
};
import { onShow } from "@dcloudio/uni-app";
onShow(async () => {
if (isLoggedIn.value) {
userStore.fetchUserInfo();
try {
const s = await getMyStats();
stats.value = s;
} catch (e) { /* ignore */ }
try {
const r = await getUnreadCount();
unreadCount.value = r.count || 0;
} catch (e) { /* ignore */ }
}
});
</script>
<template>
<view class="mine-page">
<view v-if="isLoggedIn" class="user-card">
<view class="avatar-area">
<image
v-if="user?.avatar_url"
class="avatar"
:src="resolveImageUrl(user.avatar_url)"
mode="aspectFill"
/>
<view v-else class="avatar avatar-default">
<uni-icons type="person" size="28" color="#ffffff" class="avatar-icon" />
</view>
</view>
<view class="user-info">
<text class="nickname">{{ user?.nickname || "加载中..." }}</text>
<text v-if="user?.bio" class="user-bio">{{ user.bio }}</text>
<view class="user-meta">
<view v-if="user?.city" class="meta-item"><uni-icons type="location" size="14" color="rgba(255,255,255,0.8)" /> {{ user.city }}</view>
<view v-if="user?.identity" class="identity-badge">
<text class="identity-text">{{ user.identity }}</text>
</view>
</view>
</view>
</view>
<view v-else class="login-card">
<view class="login-avatar">
<uni-icons type="person" size="28" color="#6366f1" class="login-avatar-icon" />
</view>
<text class="login-hint">请先登录</text>
<button class="login-btn" @tap="goLogin">立即登录</button>
</view>
<view v-if="isLoggedIn" class="stats-card">
<view class="stat-item" @tap="handleMenu('my-spots')">
<text class="stat-num">{{ stats.spot_count }}</text>
<text class="stat-label">投稿</text>
</view>
<view class="stat-item" @tap="handleMenu('favorites')">
<text class="stat-num">{{ stats.favorite_count }}</text>
<text class="stat-label">收藏</text>
</view>
<view class="stat-item">
<text class="stat-num">{{ stats.rating_received }}</text>
<text class="stat-label">获赞</text>
</view>
<view class="stat-item">
<text class="stat-num">{{ stats.approved_count }}</text>
<text class="stat-label">通过</text>
</view>
</view>
<view class="menu-card">
<view
v-for="(item, idx) in menuItems"
:key="idx"
class="menu-item"
@tap="handleMenu(item.action)"
>
<view class="menu-left">
<uni-icons :type="item.icon" size="22" color="#6366f1" class="menu-icon" />
<text class="menu-label">{{ item.label }}</text>
</view>
<view class="menu-right">
<view v-if="item.action === 'notifications' && unreadCount > 0" class="badge">
<text class="badge-text">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view>
<uni-icons type="right" size="16" color="#cbd5e1" class="menu-arrow" />
</view>
</view>
</view>
<view v-if="isLoggedIn" class="logout-area">
<button class="logout-btn" @tap="handleLogout">退出登录</button>
</view>
</view>
</template>
<style scoped>
.mine-page {
min-height: 100vh;
background: #f5f6fa;
padding: 0 32rpx;
padding-top: 32rpx;
}
.user-card {
background: linear-gradient(135deg, #6366f1, #818cf8);
border-radius: 24rpx;
padding: 40rpx 32rpx;
display: flex;
align-items: center;
margin-bottom: 32rpx;
}
.avatar-area {
margin-right: 24rpx;
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
}
.avatar-default {
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-icon {
font-size: 56rpx;
}
.user-info {
flex: 1;
}
.nickname {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
display: block;
margin-bottom: 8rpx;
}
.user-bio {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
display: block;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
.user-meta {
display: flex;
align-items: center;
gap: 16rpx;
}
.meta-item {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.identity-badge {
background: rgba(255, 255, 255, 0.2);
padding: 4rpx 16rpx;
border-radius: 8rpx;
}
.identity-text {
font-size: 22rpx;
color: #ffffff;
}
.login-card {
background: #ffffff;
border-radius: 24rpx;
padding: 60rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.login-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background: #e0e7ff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
}
.login-avatar-icon {
font-size: 56rpx;
}
.login-hint {
font-size: 30rpx;
color: #64748b;
margin-bottom: 32rpx;
}
.login-btn {
width: 320rpx;
height: 80rpx;
line-height: 80rpx;
background: #6366f1;
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
border-radius: 40rpx;
border: none;
}
.login-btn::after {
border: none;
}
.stats-card {
display: flex;
background: #ffffff;
border-radius: 16rpx;
padding: 28rpx 0;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-num {
font-size: 36rpx;
font-weight: 700;
color: #1e293b;
}
.stat-label {
font-size: 22rpx;
color: #94a3b8;
}
.menu-right {
display: flex;
align-items: center;
gap: 8rpx;
}
.badge {
background: #ef4444;
min-width: 32rpx;
height: 32rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
}
.badge-text {
font-size: 20rpx;
color: #fff;
font-weight: 600;
}
.menu-card {
background: #ffffff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 32rpx;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #f1f5f9;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-left {
display: flex;
align-items: center;
}
.menu-icon {
font-size: 36rpx;
margin-right: 20rpx;
}
.menu-label {
font-size: 30rpx;
color: #1e293b;
}
.menu-arrow {
font-size: 36rpx;
color: #cbd5e1;
}
.logout-area {
padding: 16rpx 0 48rpx;
}
.logout-btn {
width: 100%;
height: 84rpx;
line-height: 84rpx;
background: #ffffff;
color: #ef4444;
font-size: 30rpx;
border-radius: 16rpx;
border: none;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.logout-btn::after {
border: none;
}
</style>
+260
View File
@@ -0,0 +1,260 @@
<script setup>
import { ref, onMounted } from "vue";
import { getMembershipPlans, getMyMembership, purchaseMembership } from "@/api/membership";
const plans = ref([]);
const myMembership = ref(null);
const loading = ref(true);
async function loadData() {
loading.value = true;
try {
const [plansRes, myRes] = await Promise.all([
getMembershipPlans(),
getMyMembership(),
]);
plans.value = Array.isArray(plansRes) ? plansRes : [];
myMembership.value = myRes || null;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
async function handlePurchase(planId) {
uni.showModal({
title: "确认开通",
content: "确认购买该会员方案?",
success: async (r) => {
if (!r.confirm) return;
try {
const res = await purchaseMembership(planId);
myMembership.value = res;
uni.showToast({ title: "开通成功", icon: "success" });
} catch (e) {
uni.showToast({ title: e.message || "购买失败", icon: "none" });
}
},
});
}
function formatDate(d) {
if (!d) return "";
const dt = new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
}
function daysLeft(endDate) {
if (!endDate) return 0;
const diff = new Date(endDate).getTime() - Date.now();
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
}
onMounted(loadData);
</script>
<template>
<view class="membership-page">
<view v-if="loading" class="loading-tip">加载中...</view>
<template v-else>
<!-- Current membership -->
<view v-if="myMembership" class="current-card">
<view class="current-header">
<uni-icons type="star-filled" size="24" color="#f59e0b" />
<text class="current-title">{{ myMembership.plan?.name || '会员' }}</text>
</view>
<view class="current-info">
<view class="current-row">
<text class="current-label">到期时间</text>
<text class="current-value">{{ formatDate(myMembership.end_date) }}</text>
</view>
<view class="current-row">
<text class="current-label">剩余天数</text>
<text class="current-value highlight">{{ daysLeft(myMembership.end_date) }}</text>
</view>
</view>
</view>
<view v-else class="no-member-card">
<uni-icons type="star" size="32" color="#d1d5db" />
<text class="no-member-text">您还不是会员</text>
</view>
<!-- Plans -->
<view class="section-title">会员方案</view>
<view v-if="plans.length === 0" class="empty-tip">暂无可用方案</view>
<view v-for="plan in plans" :key="plan.id" class="plan-card">
<view class="plan-header">
<text class="plan-name">{{ plan.name }}</text>
<text class="plan-price">¥{{ plan.price }}</text>
</view>
<text v-if="plan.description" class="plan-desc">{{ plan.description }}</text>
<view class="plan-meta">
<text class="plan-duration">{{ plan.duration_days }}</text>
<text v-if="plan.extra_uploads" class="plan-benefit">+{{ plan.extra_uploads }}上传额度</text>
<text v-if="plan.extra_top_count" class="plan-benefit">+{{ plan.extra_top_count }}置顶次数</text>
</view>
<view v-if="plan.benefits" class="plan-benefits">
<text class="benefits-text">{{ plan.benefits }}</text>
</view>
<view class="plan-action">
<view class="purchase-btn" @tap="handlePurchase(plan.id)">
{{ myMembership ? '续费' : '立即开通' }}
</view>
</view>
</view>
</template>
</view>
</template>
<style scoped>
.membership-page {
min-height: 100vh;
background: #f5f6fa;
padding: 20rpx;
}
.loading-tip {
text-align: center;
padding: 100rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
.current-card {
background: linear-gradient(135deg, #f59e0b, #fbbf24);
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.current-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.current-title {
font-size: 32rpx;
font-weight: 700;
color: #fff;
}
.current-info {
display: flex;
gap: 40rpx;
}
.current-row {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.current-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
.current-value {
font-size: 26rpx;
color: #fff;
font-weight: 600;
}
.current-value.highlight {
font-size: 32rpx;
}
.no-member-card {
background: #fff;
border-radius: 20rpx;
padding: 48rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
margin-bottom: 24rpx;
}
.no-member-text {
font-size: 28rpx;
color: #9ca3af;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1e1e2e;
margin-bottom: 16rpx;
padding-left: 8rpx;
}
.empty-tip {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 40rpx 0;
}
.plan-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.plan-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.plan-name {
font-size: 30rpx;
font-weight: 700;
color: #1e1e2e;
}
.plan-price {
font-size: 32rpx;
font-weight: 700;
color: #ef4444;
}
.plan-desc {
font-size: 24rpx;
color: #6b7280;
margin-bottom: 12rpx;
}
.plan-meta {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
margin-bottom: 12rpx;
}
.plan-duration {
font-size: 24rpx;
color: #6366f1;
background: #eef2ff;
padding: 4rpx 16rpx;
border-radius: 16rpx;
}
.plan-benefit {
font-size: 24rpx;
color: #d97706;
background: #fef3c7;
padding: 4rpx 16rpx;
border-radius: 16rpx;
}
.plan-benefits {
margin-bottom: 16rpx;
}
.benefits-text {
font-size: 24rpx;
color: #4b5563;
line-height: 1.6;
white-space: pre-wrap;
}
.plan-action {
display: flex;
justify-content: flex-end;
}
.purchase-btn {
padding: 14rpx 40rpx;
background: #6366f1;
color: #fff;
font-size: 26rpx;
font-weight: 600;
border-radius: 32rpx;
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { getMySpots, deleteSpot } from "@/api/spot";
import { extractList } from "@/utils/request";
import { resolveImageUrl } from "@/utils/image";
import { checkLogin } from "@/utils/auth";
import { formatSpotPrice } from "@/utils/spot";
onShow(() => {
if (checkLogin()) fetchSpots(true);
});
const spots = ref([]);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
const loading = ref(false);
const statusMap = {
pending: { text: "待审核", color: "#f59e0b", bg: "rgba(245,158,11,0.1)" },
approved: { text: "已通过", color: "#22c55e", bg: "rgba(34,197,94,0.1)" },
rejected: { text: "已拒绝", color: "#ef4444", bg: "rgba(239,68,68,0.1)" },
};
const getStatus = (s) => statusMap[s] || { text: s, color: "#94a3b8", bg: "#f5f6fa" };
const fetchSpots = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const res = await getMySpots({ page: page.value, page_size: pageSize });
const list = extractList(res);
if (reset) {
spots.value = list;
} else {
spots.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const goDetail = (id) => {
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
};
const goEdit = (id) => {
uni.navigateTo({ url: `/pages/spot/edit?id=${id}` });
};
const handleDelete = (item) => {
uni.showModal({
title: "确认删除",
content: `确定要删除「${item.title}」吗?此操作不可撤销。`,
confirmColor: "#ef4444",
success: async (res) => {
if (res.confirm) {
try {
await deleteSpot(item.id);
uni.showToast({ title: "已删除", icon: "success" });
spots.value = spots.value.filter((s) => s.id !== item.id);
} catch (e) {
console.error(e);
}
}
},
});
};
onPullDownRefresh(async () => {
await fetchSpots(true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
fetchSpots();
});
fetchSpots(true);
</script>
<template>
<view class="my-spots-page">
<view class="list">
<view
v-for="item in spots"
:key="item.id"
class="spot-card"
@tap="goDetail(item.id)"
>
<image
v-if="item.cover_image_url"
class="cover"
:src="resolveImageUrl(item.cover_image_url)"
mode="aspectFill"
/>
<view v-else class="cover cover-placeholder">
<uni-icons type="camera" size="32" color="#94a3b8" class="placeholder-icon" />
</view>
<view class="info">
<view class="title-row">
<text class="title">{{ item.title }}</text>
<view
class="status-badge"
:style="{ background: getStatus(item.audit_status).bg }"
>
<text
class="status-text"
:style="{ color: getStatus(item.audit_status).color }"
>
{{ getStatus(item.audit_status).text }}
</text>
</view>
</view>
<view class="meta-row">
<view class="city"><uni-icons type="location" size="14" color="#64748b" /> {{ item.city || "未知城市" }}</view>
<text
class="price-text"
:class="{ free: formatSpotPrice(item).isFree, paid: !formatSpotPrice(item).isFree }"
>
{{ formatSpotPrice(item).label }}
</text>
</view>
<view
v-if="item.audit_status === 'rejected' && item.reject_reason"
class="reject-row"
>
<text class="reject-label">拒绝原因</text>
<text class="reject-reason">{{ item.reject_reason }}</text>
</view>
<view class="action-row">
<view class="action-btn edit-btn" @tap.stop="goEdit(item.id)">
<uni-icons type="compose" size="16" color="#6366f1" />
<text class="action-text edit-text">编辑</text>
</view>
<view class="action-btn delete-btn" @tap.stop="handleDelete(item)">
<uni-icons type="trash" size="16" color="#ef4444" />
<text class="action-text delete-text">删除</text>
</view>
</view>
</view>
</view>
<view v-if="loading" class="status-tip">
<text>加载中...</text>
</view>
<view v-else-if="!hasMore && spots.length > 0" class="status-tip">
<text>没有更多了</text>
</view>
<view v-else-if="!loading && spots.length === 0" class="empty-state">
<uni-icons type="location" size="48" color="#6366f1" class="empty-icon" />
<text class="empty-title">还没有投稿地点</text>
<text class="empty-desc">去投稿你发现的取景地吧</text>
</view>
</view>
</view>
</template>
<style scoped>
.my-spots-page {
min-height: 100vh;
background: #f5f6fa;
}
.list {
padding: 24rpx 32rpx;
}
.spot-card {
background: #ffffff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
margin-bottom: 24rpx;
}
.cover {
width: 100%;
height: 280rpx;
}
.cover-placeholder {
background: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-icon {
font-size: 64rpx;
}
.info {
padding: 20rpx 24rpx 24rpx;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.title {
font-size: 30rpx;
font-weight: 600;
color: #1e293b;
flex: 1;
margin-right: 16rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
padding: 6rpx 16rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.status-text {
font-size: 22rpx;
font-weight: 600;
}
.meta-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
flex-wrap: wrap;
}
.city {
font-size: 24rpx;
color: #64748b;
}
.price-text {
font-size: 24rpx;
font-weight: 600;
}
.price-text.free {
color: #16a34a;
}
.price-text.paid {
color: #d97706;
}
.reject-row {
margin-top: 12rpx;
background: rgba(239, 68, 68, 0.06);
padding: 16rpx 20rpx;
border-radius: 10rpx;
}
.reject-label {
font-size: 24rpx;
color: #ef4444;
font-weight: 500;
}
.reject-reason {
font-size: 24rpx;
color: #64748b;
line-height: 1.5;
}
.action-row {
display: flex;
gap: 16rpx;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f1f5f9;
}
.action-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 24rpx;
border-radius: 8rpx;
}
.edit-btn {
background: rgba(99, 102, 241, 0.08);
}
.delete-btn {
background: rgba(239, 68, 68, 0.08);
}
.action-text {
font-size: 24rpx;
font-weight: 500;
}
.edit-text {
color: #6366f1;
}
.delete-text {
color: #ef4444;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 0;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94a3b8;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { getNotifications, markAllRead, markRead } from "@/api/notification";
import { extractList } from "@/utils/request";
import { checkLogin } from "@/utils/auth";
onShow(() => { checkLogin(); });
const items = ref([]);
const page = ref(1);
const pageSize = 20;
const hasMore = ref(true);
const loading = ref(false);
const typeIcon = {
audit: "checkbox-filled",
comment: "chat",
system: "info",
};
const typeColor = {
audit: "#6366f1",
comment: "#3b82f6",
system: "#94a3b8",
};
const fetchList = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) { page.value = 1; hasMore.value = true; }
try {
const res = await getNotifications({ page: page.value, page_size: pageSize });
const list = extractList(res);
if (reset) items.value = list; else items.value.push(...list);
if (list.length < pageSize) hasMore.value = false; else page.value++;
} catch (e) { console.error(e); }
finally { loading.value = false; }
};
const handleTap = async (item) => {
if (!item.is_read) {
try { await markRead(item.id); item.is_read = true; } catch (e) { /* */ }
}
if (item.ref_type === "spot" && item.ref_id) {
uni.navigateTo({ url: `/pages/spot/detail?id=${item.ref_id}` });
}
};
const handleReadAll = async () => {
try {
await markAllRead();
items.value.forEach((n) => (n.is_read = true));
uni.showToast({ title: "已全部标记已读", icon: "success" });
} catch (e) { console.error(e); }
};
onPullDownRefresh(async () => { await fetchList(true); uni.stopPullDownRefresh(); });
onReachBottom(() => { fetchList(); });
fetchList(true);
</script>
<template>
<view class="notification-page">
<view class="header-bar">
<text class="header-title">消息通知</text>
<text class="read-all" @tap="handleReadAll">全部已读</text>
</view>
<view class="list">
<view
v-for="item in items"
:key="item.id"
class="noti-item"
:class="{ unread: !item.is_read }"
@tap="handleTap(item)"
>
<view class="noti-icon" :style="{ background: (typeColor[item.type] || '#94a3b8') + '18' }">
<uni-icons :type="typeIcon[item.type] || 'info'" size="20" :color="typeColor[item.type] || '#94a3b8'" />
</view>
<view class="noti-body">
<text class="noti-title">{{ item.title }}</text>
<text v-if="item.content" class="noti-content">{{ item.content }}</text>
<text class="noti-time">{{ item.created_at?.slice(0, 16).replace('T', ' ') }}</text>
</view>
<view v-if="!item.is_read" class="noti-dot" />
</view>
</view>
<view v-if="loading" class="status-tip"><text>加载中...</text></view>
<view v-else-if="!hasMore && items.length > 0" class="status-tip"><text>没有更多了</text></view>
<view v-else-if="!loading && items.length === 0" class="empty-state">
<uni-icons type="chat" size="48" color="#cbd5e1" />
<text class="empty-text">暂无消息</text>
</view>
</view>
</template>
<style scoped>
.notification-page {
min-height: 100vh;
background: #f5f6fa;
}
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
}
.header-title {
font-size: 32rpx;
font-weight: 700;
color: #1e293b;
}
.read-all {
font-size: 26rpx;
color: #6366f1;
}
.list {
padding: 0 32rpx;
}
.noti-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 24rpx;
background: #fff;
border-radius: 16rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
position: relative;
}
.noti-item.unread {
background: #f0f0ff;
}
.noti-icon {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.noti-body {
flex: 1;
min-width: 0;
}
.noti-title {
font-size: 28rpx;
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 6rpx;
}
.noti-content {
font-size: 24rpx;
color: #64748b;
display: block;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.noti-time {
font-size: 22rpx;
color: #94a3b8;
}
.noti-dot {
width: 16rpx;
height: 16rpx;
background: #ef4444;
border-radius: 8rpx;
position: absolute;
top: 28rpx;
right: 24rpx;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 0;
}
.empty-text {
margin-top: 16rpx;
font-size: 28rpx;
color: #94a3b8;
}
</style>
+247
View File
@@ -0,0 +1,247 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { getMyPoints, getMyPointRecords } from "@/api/point";
import { checkLogin } from "@/utils/auth";
onShow(() => { checkLogin(); });
const balance = ref(0);
const records = ref([]);
const page = ref(1);
const pageSize = 15;
const hasMore = ref(true);
const loading = ref(false);
const fetchBalance = async () => {
try {
const res = await getMyPoints();
balance.value = res.balance ?? res.points ?? 0;
} catch (e) {
console.error(e);
}
};
const fetchRecords = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const res = await getMyPointRecords({ page: page.value, page_size: pageSize });
const list = res.items || res.data || res || [];
if (reset) {
records.value = list;
} else {
records.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return `${y}-${m}-${day} ${h}:${min}`;
};
const formatChange = (val) => {
return val > 0 ? `+${val}` : `${val}`;
};
onPullDownRefresh(async () => {
await Promise.all([fetchBalance(), fetchRecords(true)]);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
fetchRecords();
});
fetchBalance();
fetchRecords(true);
</script>
<template>
<view class="points-page">
<view class="balance-card">
<text class="balance-label">当前积分</text>
<text class="balance-value">{{ balance }}</text>
</view>
<view class="section-header">
<text class="section-title">积分记录</text>
</view>
<view class="records-list">
<view
v-for="item in records"
:key="item.id"
class="record-item"
>
<view class="record-left">
<text class="record-reason">{{ item.reason || "积分变动" }}</text>
<text class="record-date">{{ formatDate(item.created_at) }}</text>
</view>
<text
class="record-change"
:class="item.change > 0 ? 'positive' : 'negative'"
>
{{ formatChange(item.change) }}
</text>
</view>
<view v-if="loading" class="status-tip">
<text>加载中...</text>
</view>
<view v-else-if="!hasMore && records.length > 0" class="status-tip">
<text>没有更多了</text>
</view>
<view v-else-if="!loading && records.length === 0" class="empty-state">
<uni-icons type="star-filled" size="48" color="#6366f1" class="empty-icon" />
<text class="empty-title">暂无积分记录</text>
<text class="empty-desc">投稿取景地获得收藏可以赚取积分</text>
</view>
</view>
</view>
</template>
<style scoped>
.points-page {
min-height: 100vh;
background: #f5f6fa;
}
.balance-card {
margin: 24rpx 32rpx;
background: linear-gradient(135deg, #6366f1, #818cf8);
border-radius: 24rpx;
padding: 48rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 8rpx 32rpx rgba(99, 102, 241, 0.25);
}
.balance-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 16rpx;
}
.balance-value {
font-size: 80rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.section-header {
padding: 24rpx 32rpx 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1e293b;
}
.records-list {
padding: 0 32rpx 32rpx;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
padding: 28rpx 28rpx;
border-radius: 14rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.record-left {
flex: 1;
margin-right: 20rpx;
}
.record-reason {
font-size: 28rpx;
color: #1e293b;
font-weight: 500;
display: block;
margin-bottom: 6rpx;
}
.record-date {
font-size: 24rpx;
color: #94a3b8;
display: block;
}
.record-change {
font-size: 34rpx;
font-weight: 700;
flex-shrink: 0;
}
.record-change.positive {
color: #22c55e;
}
.record-change.negative {
color: #ef4444;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94a3b8;
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<script setup>
import { ref, onMounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { useUserStore } from "@/store/user";
import { updateMyInfo } from "@/api/user";
import { uploadImage } from "@/api/spot";
import { resolveImageUrl } from "@/utils/image";
import { checkLogin } from "@/utils/auth";
import cityData from "@/utils/city-data";
const userStore = useUserStore();
const loading = ref(false);
const form = ref({
nickname: "",
avatar_url: "",
city: "",
bio: "",
identity: "both",
});
const identityOptions = [
{ label: "摄影师", value: "photographer" },
{ label: "Coser", value: "cosplayer" },
{ label: "都是", value: "both" },
];
const provinces = cityData.map((p) => p.province);
const cityColumns = ref([provinces, cityData[0].cities]);
const cityPickerIndex = ref([0, 0]);
const initForm = () => {
const u = userStore.userInfo;
if (!u) return;
form.value.nickname = u.nickname || "";
form.value.avatar_url = u.avatar_url || "";
form.value.bio = u.bio || "";
form.value.identity = u.identity || "both";
form.value.city = u.city || "";
if (u.city) {
const parts = u.city.split(" ");
if (parts.length === 2) {
const pi = provinces.indexOf(parts[0]);
if (pi >= 0) {
const ci = cityData[pi].cities.indexOf(parts[1]);
cityPickerIndex.value = [pi, ci >= 0 ? ci : 0];
cityColumns.value = [provinces, cityData[pi].cities];
}
}
}
};
onShow(() => {
if (!checkLogin()) return;
});
onMounted(async () => {
await userStore.fetchUserInfo();
initForm();
});
const onCityColumnChange = (e) => {
const { column, value } = e.detail;
if (column === 0) {
cityColumns.value = [provinces, cityData[value].cities];
cityPickerIndex.value = [value, 0];
} else {
cityPickerIndex.value = [cityPickerIndex.value[0], value];
}
};
const onCityChange = (e) => {
const [pi, ci] = e.detail.value;
const province = provinces[pi];
const city = cityData[pi].cities[ci];
form.value.city = `${province} ${city}`;
};
const chooseAvatar = () => {
uni.chooseImage({
count: 1,
sizeType: ["compressed"],
success: async (res) => {
const tempPath = res.tempFilePaths[0];
try {
uni.showLoading({ title: "上传中..." });
const data = await uploadImage(tempPath);
form.value.avatar_url = data.url;
uni.hideLoading();
} catch (e) {
uni.hideLoading();
uni.showToast({ title: "头像上传失败", icon: "none" });
}
},
});
};
const handleSave = async () => {
if (!form.value.nickname.trim()) {
uni.showToast({ title: "昵称不能为空", icon: "none" });
return;
}
loading.value = true;
try {
await updateMyInfo({
nickname: form.value.nickname.trim(),
avatar_url: form.value.avatar_url || null,
city: form.value.city || null,
bio: form.value.bio.trim() || null,
identity: form.value.identity,
});
await userStore.fetchUserInfo();
uni.showToast({ title: "保存成功", icon: "success" });
setTimeout(() => uni.navigateBack(), 800);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
</script>
<template>
<view class="profile-page">
<view class="avatar-section" @tap="chooseAvatar">
<image
v-if="form.avatar_url"
class="avatar-img"
:src="resolveImageUrl(form.avatar_url)"
mode="aspectFill"
/>
<view v-else class="avatar-img avatar-placeholder">
<uni-icons type="camera-filled" size="32" color="#94a3b8" />
</view>
<text class="avatar-tip">点击更换头像</text>
</view>
<view class="form-card">
<view class="field">
<text class="label">昵称</text>
<input
v-model="form.nickname"
class="input"
maxlength="20"
placeholder="请输入昵称"
placeholder-class="placeholder"
/>
</view>
<view class="field">
<text class="label">个人简介</text>
<textarea
v-model="form.bio"
class="textarea"
maxlength="120"
placeholder="一句话介绍自己"
placeholder-class="placeholder"
:auto-height="false"
/>
</view>
<view class="field">
<text class="label">所在城市</text>
<picker
mode="multiSelector"
:value="cityPickerIndex"
:range="cityColumns"
@columnchange="onCityColumnChange"
@change="onCityChange"
>
<view class="picker-value">
<text :class="form.city ? '' : 'placeholder'">{{ form.city || '请选择城市' }}</text>
<uni-icons type="right" size="16" color="#cbd5e1" />
</view>
</picker>
</view>
<view class="field">
<text class="label">身份</text>
<view class="identity-toggle">
<view
v-for="opt in identityOptions"
:key="opt.value"
class="toggle-btn"
:class="{ active: form.identity === opt.value }"
@tap="form.identity = opt.value"
>
<text class="toggle-text">{{ opt.label }}</text>
</view>
</view>
</view>
</view>
<button class="save-btn" :loading="loading" :disabled="loading" @tap="handleSave">
保存
</button>
</view>
</template>
<style scoped>
.profile-page {
min-height: 100vh;
background: #f5f6fa;
padding: 32rpx;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32rpx;
}
.avatar-img {
width: 160rpx;
height: 160rpx;
border-radius: 80rpx;
border: 4rpx solid #e2e8f0;
}
.avatar-placeholder {
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-tip {
font-size: 24rpx;
color: #6366f1;
margin-top: 12rpx;
}
.form-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 40rpx;
}
.field {
margin-bottom: 28rpx;
}
.field:last-child {
margin-bottom: 0;
}
.label {
display: block;
font-size: 26rpx;
color: #64748b;
margin-bottom: 12rpx;
font-weight: 500;
}
.input {
width: 100%;
height: 80rpx;
background: #f8fafc;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1e293b;
box-sizing: border-box;
}
.textarea {
width: 100%;
height: 160rpx;
background: #f8fafc;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: #1e293b;
box-sizing: border-box;
}
.placeholder {
color: #94a3b8;
}
.picker-value {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
background: #f8fafc;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1e293b;
}
.identity-toggle {
display: flex;
gap: 16rpx;
}
.toggle-btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f6fa;
border-radius: 12rpx;
border: 2rpx solid #e2e8f0;
}
.toggle-btn.active {
background: rgba(99, 102, 241, 0.12);
border-color: #6366f1;
}
.toggle-text {
font-size: 28rpx;
color: #64748b;
}
.toggle-btn.active .toggle-text {
color: #6366f1;
font-weight: 600;
}
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #6366f1;
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
border-radius: 16rpx;
border: none;
}
.save-btn::after {
border: none;
}
.save-btn[disabled] {
opacity: 0.6;
}
</style>
+231
View File
@@ -0,0 +1,231 @@
<script setup>
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { checkLogin } from "@/utils/auth";
import { changePassword } from "@/api/user";
import { useUserStore } from "@/store/user";
onShow(() => { checkLogin(); });
const userStore = useUserStore();
const showPwdForm = ref(false);
const pwdForm = ref({ old_password: "", new_password: "", confirm: "" });
const saving = ref(false);
const handleChangePwd = async () => {
if (!pwdForm.value.old_password || !pwdForm.value.new_password) {
uni.showToast({ title: "请填写完整", icon: "none" });
return;
}
if (pwdForm.value.new_password.length < 6) {
uni.showToast({ title: "新密码至少6位", icon: "none" });
return;
}
if (pwdForm.value.new_password !== pwdForm.value.confirm) {
uni.showToast({ title: "两次密码不一致", icon: "none" });
return;
}
saving.value = true;
try {
await changePassword({
old_password: pwdForm.value.old_password,
new_password: pwdForm.value.new_password,
});
uni.showToast({ title: "密码修改成功", icon: "success" });
showPwdForm.value = false;
pwdForm.value = { old_password: "", new_password: "", confirm: "" };
} catch (e) {
console.error(e);
} finally {
saving.value = false;
}
};
const clearCache = () => {
uni.showModal({
title: "清除缓存",
content: "将清除本地缓存数据(不会影响账号数据),确认继续?",
success: (res) => {
if (res.confirm) {
const token = uni.getStorageSync("access_token");
const rt = uni.getStorageSync("refresh_token");
uni.clearStorageSync();
if (token) uni.setStorageSync("access_token", token);
if (rt) uni.setStorageSync("refresh_token", rt);
uni.showToast({ title: "缓存已清除", icon: "success" });
}
},
});
};
const handleLogout = () => {
uni.showModal({
title: "提示",
content: "确定要退出登录吗?",
success: (res) => {
if (res.confirm) userStore.logout();
},
});
};
</script>
<template>
<view class="settings-page">
<view class="menu-card">
<view class="menu-item" @tap="showPwdForm = !showPwdForm">
<view class="menu-left">
<uni-icons type="locked" size="22" color="#6366f1" />
<text class="menu-label">修改密码</text>
</view>
<uni-icons :type="showPwdForm ? 'arrowup' : 'right'" size="16" color="#cbd5e1" />
</view>
<view v-if="showPwdForm" class="pwd-form">
<input
v-model="pwdForm.old_password"
class="pwd-input"
type="password"
placeholder="当前密码"
placeholder-class="placeholder"
/>
<input
v-model="pwdForm.new_password"
class="pwd-input"
type="password"
placeholder="新密码(至少6位)"
placeholder-class="placeholder"
/>
<input
v-model="pwdForm.confirm"
class="pwd-input"
type="password"
placeholder="确认新密码"
placeholder-class="placeholder"
/>
<button class="pwd-btn" :loading="saving" :disabled="saving" @tap="handleChangePwd">
确认修改
</button>
</view>
<view class="menu-item" @tap="clearCache">
<view class="menu-left">
<uni-icons type="trash" size="22" color="#f59e0b" />
<text class="menu-label">清除缓存</text>
</view>
<uni-icons type="right" size="16" color="#cbd5e1" />
</view>
</view>
<view class="about-card">
<text class="about-title">关于次元取景器</text>
<text class="about-desc">发现和分享二次元取景地的社区平台</text>
<text class="about-version">v1.0.0</text>
</view>
<view class="logout-area">
<button class="logout-btn" @tap="handleLogout">退出登录</button>
</view>
</view>
</template>
<style scoped>
.settings-page {
min-height: 100vh;
background: #f5f6fa;
padding: 24rpx 32rpx;
}
.menu-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 32rpx;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #f1f5f9;
}
.menu-item:last-child { border-bottom: none; }
.menu-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.menu-label {
font-size: 30rpx;
color: #1e293b;
}
.pwd-form {
padding: 20rpx 32rpx 28rpx;
background: #f8fafc;
}
.pwd-input {
width: 100%;
height: 80rpx;
background: #fff;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1e293b;
box-sizing: border-box;
margin-bottom: 16rpx;
}
.placeholder { color: #94a3b8; }
.pwd-btn {
width: 100%;
height: 80rpx;
line-height: 80rpx;
background: #6366f1;
color: #fff;
font-size: 28rpx;
font-weight: 600;
border-radius: 12rpx;
border: none;
margin-top: 8rpx;
}
.pwd-btn::after { border: none; }
.pwd-btn[disabled] { opacity: 0.6; }
.about-card {
background: #fff;
border-radius: 16rpx;
padding: 40rpx 32rpx;
text-align: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 32rpx;
}
.about-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: #1e293b;
margin-bottom: 12rpx;
}
.about-desc {
display: block;
font-size: 26rpx;
color: #64748b;
margin-bottom: 8rpx;
}
.about-version {
display: block;
font-size: 22rpx;
color: #94a3b8;
}
.logout-area { padding: 16rpx 0 48rpx; }
.logout-btn {
width: 100%;
height: 84rpx;
line-height: 84rpx;
background: #fff;
color: #ef4444;
font-size: 30rpx;
border-radius: 16rpx;
border: none;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.logout-btn::after { border: none; }
</style>