feat(app): add announcements, feedback, settings (stage 5)
This commit is contained in:
12
app-frontend/src/api/announcements.js
Normal file
12
app-frontend/src/api/announcements.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { publicApi } from './http'
|
||||
|
||||
export async function fetchActiveAnnouncement() {
|
||||
const { data } = await publicApi.get('/announcements/active')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function dismissAnnouncement(announcementId) {
|
||||
const { data } = await publicApi.post(`/announcements/${announcementId}/dismiss`, {})
|
||||
return data
|
||||
}
|
||||
|
||||
12
app-frontend/src/api/feedback.js
Normal file
12
app-frontend/src/api/feedback.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { publicApi } from './http'
|
||||
|
||||
export async function submitFeedback(payload) {
|
||||
const { data } = await publicApi.post('/feedback', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchMyFeedbacks() {
|
||||
const { data } = await publicApi.get('/feedback')
|
||||
return data
|
||||
}
|
||||
|
||||
32
app-frontend/src/api/settings.js
Normal file
32
app-frontend/src/api/settings.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { publicApi } from './http'
|
||||
|
||||
export async function fetchUserEmail() {
|
||||
const { data } = await publicApi.get('/user/email')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function bindEmail(payload) {
|
||||
const { data } = await publicApi.post('/user/bind-email', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unbindEmail() {
|
||||
const { data } = await publicApi.post('/user/unbind-email', {})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchEmailNotify() {
|
||||
const { data } = await publicApi.get('/user/email-notify')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateEmailNotify(payload) {
|
||||
const { data } = await publicApi.post('/user/email-notify', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function changePassword(payload) {
|
||||
const { data } = await publicApi.post('/user/password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Calendar, Camera, User } from '@element-plus/icons-vue'
|
||||
|
||||
import { fetchActiveAnnouncement, dismissAnnouncement } from '../api/announcements'
|
||||
import { fetchMyFeedbacks, submitFeedback } from '../api/feedback'
|
||||
import {
|
||||
bindEmail,
|
||||
changePassword,
|
||||
fetchEmailNotify,
|
||||
fetchUserEmail,
|
||||
unbindEmail,
|
||||
updateEmailNotify,
|
||||
} from '../api/settings'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -14,6 +24,42 @@ const isMobile = ref(false)
|
||||
const drawerOpen = ref(false)
|
||||
let mediaQuery
|
||||
|
||||
const announcementOpen = ref(false)
|
||||
const announcement = ref(null)
|
||||
const announcementLoading = ref(false)
|
||||
|
||||
const feedbackOpen = ref(false)
|
||||
const feedbackTab = ref('new')
|
||||
const feedbackSubmitting = ref(false)
|
||||
const feedbackLoading = ref(false)
|
||||
const myFeedbacks = ref([])
|
||||
const feedbackForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
contact: '',
|
||||
})
|
||||
|
||||
const settingsOpen = ref(false)
|
||||
const settingsTab = ref('email')
|
||||
|
||||
const emailLoading = ref(false)
|
||||
const bindEmailLoading = ref(false)
|
||||
const emailInfo = reactive({
|
||||
email: '',
|
||||
email_verified: false,
|
||||
})
|
||||
const bindEmailValue = ref('')
|
||||
|
||||
const emailNotifyLoading = ref(false)
|
||||
const emailNotifyEnabled = ref(true)
|
||||
|
||||
const passwordLoading = ref(false)
|
||||
const passwordForm = reactive({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
|
||||
function syncIsMobile() {
|
||||
isMobile.value = Boolean(mediaQuery?.matches)
|
||||
if (!isMobile.value) drawerOpen.value = false
|
||||
@@ -27,6 +73,8 @@ onMounted(() => {
|
||||
userStore.refreshVipInfo().catch(() => {
|
||||
window.location.href = '/login'
|
||||
})
|
||||
|
||||
loadAnnouncement()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -60,6 +108,258 @@ async function logout() {
|
||||
await userStore.logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
function openFeedbackForm() {
|
||||
feedbackTab.value = 'new'
|
||||
feedbackForm.title = ''
|
||||
feedbackForm.description = ''
|
||||
feedbackForm.contact = ''
|
||||
feedbackOpen.value = true
|
||||
}
|
||||
|
||||
async function openMyFeedbacks() {
|
||||
feedbackTab.value = 'list'
|
||||
feedbackOpen.value = true
|
||||
await loadMyFeedbacks()
|
||||
}
|
||||
|
||||
async function loadMyFeedbacks() {
|
||||
feedbackLoading.value = true
|
||||
try {
|
||||
const list = await fetchMyFeedbacks()
|
||||
myFeedbacks.value = Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
myFeedbacks.value = []
|
||||
} finally {
|
||||
feedbackLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function feedbackStatusLabel(status) {
|
||||
if (status === 'replied') return '已回复'
|
||||
if (status === 'closed') return '已关闭'
|
||||
return '待处理'
|
||||
}
|
||||
|
||||
function feedbackStatusTagType(status) {
|
||||
if (status === 'replied') return 'success'
|
||||
if (status === 'closed') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function submitFeedbackForm() {
|
||||
const title = feedbackForm.title.trim()
|
||||
const description = feedbackForm.description.trim()
|
||||
const contact = feedbackForm.contact.trim()
|
||||
|
||||
if (!title || !description) {
|
||||
ElMessage.error('标题和描述不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
feedbackSubmitting.value = true
|
||||
try {
|
||||
const res = await submitFeedback({ title, description, contact })
|
||||
ElMessage.success(res?.message || '反馈提交成功')
|
||||
feedbackOpen.value = false
|
||||
feedbackForm.title = ''
|
||||
feedbackForm.description = ''
|
||||
feedbackForm.contact = ''
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '提交失败')
|
||||
} finally {
|
||||
feedbackSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
settingsOpen.value = true
|
||||
settingsTab.value = 'email'
|
||||
await loadSettings()
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
await Promise.all([loadEmailInfo(), loadEmailNotify()])
|
||||
}
|
||||
|
||||
async function loadEmailInfo() {
|
||||
emailLoading.value = true
|
||||
try {
|
||||
const data = await fetchUserEmail()
|
||||
emailInfo.email = data?.email || ''
|
||||
emailInfo.email_verified = Boolean(data?.email_verified)
|
||||
bindEmailValue.value = emailInfo.email || ''
|
||||
} catch {
|
||||
emailInfo.email = ''
|
||||
emailInfo.email_verified = false
|
||||
bindEmailValue.value = ''
|
||||
} finally {
|
||||
emailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmailNotify() {
|
||||
emailNotifyLoading.value = true
|
||||
try {
|
||||
const data = await fetchEmailNotify()
|
||||
emailNotifyEnabled.value = Boolean(data?.enabled)
|
||||
} catch {
|
||||
emailNotifyEnabled.value = true
|
||||
} finally {
|
||||
emailNotifyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onBindEmail() {
|
||||
const email = bindEmailValue.value.trim().toLowerCase()
|
||||
if (!email) {
|
||||
ElMessage.error('请输入邮箱地址')
|
||||
return
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(email)) {
|
||||
ElMessage.error('邮箱格式不正确')
|
||||
return
|
||||
}
|
||||
|
||||
bindEmailLoading.value = true
|
||||
try {
|
||||
const res = await bindEmail({ email })
|
||||
ElMessage.success(res?.message || '验证邮件已发送')
|
||||
emailInfo.email = email
|
||||
emailInfo.email_verified = false
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '绑定失败')
|
||||
} finally {
|
||||
bindEmailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnbindEmail() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要解绑当前邮箱吗?', '解绑邮箱', {
|
||||
confirmButtonText: '解绑',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await unbindEmail()
|
||||
if (res?.success) {
|
||||
ElMessage.success(res?.message || '邮箱已解绑')
|
||||
await loadEmailInfo()
|
||||
return
|
||||
}
|
||||
ElMessage.error(res?.error || '解绑失败')
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleEmailNotify(value) {
|
||||
const previous = emailNotifyEnabled.value
|
||||
emailNotifyEnabled.value = Boolean(value)
|
||||
emailNotifyLoading.value = true
|
||||
try {
|
||||
const res = await updateEmailNotify({ enabled: Boolean(value) })
|
||||
if (res?.success) {
|
||||
ElMessage.success('已更新')
|
||||
return
|
||||
}
|
||||
emailNotifyEnabled.value = previous
|
||||
ElMessage.error(res?.error || '更新失败')
|
||||
} catch (e) {
|
||||
emailNotifyEnabled.value = previous
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '更新失败')
|
||||
} finally {
|
||||
emailNotifyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onChangePassword() {
|
||||
const currentPassword = passwordForm.current_password
|
||||
const newPassword = passwordForm.new_password
|
||||
const confirmPassword = passwordForm.confirm_password
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
ElMessage.error('请填写完整信息')
|
||||
return
|
||||
}
|
||||
if (String(newPassword).length < 6) {
|
||||
ElMessage.error('新密码至少6位')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
ElMessage.error('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
passwordLoading.value = true
|
||||
try {
|
||||
const res = await changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
if (res?.success) {
|
||||
ElMessage.success('密码修改成功')
|
||||
passwordForm.current_password = ''
|
||||
passwordForm.new_password = ''
|
||||
passwordForm.confirm_password = ''
|
||||
return
|
||||
}
|
||||
ElMessage.error(res?.error || '修改失败')
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '修改失败')
|
||||
} finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnnouncement() {
|
||||
announcementLoading.value = true
|
||||
try {
|
||||
const data = await fetchActiveAnnouncement()
|
||||
const ann = data?.announcement
|
||||
if (!ann?.id) return
|
||||
|
||||
const sessionKey = `announcement_closed_${ann.id}`
|
||||
if (window.sessionStorage.getItem(sessionKey) === '1') return
|
||||
|
||||
announcement.value = ann
|
||||
announcementOpen.value = true
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
announcementLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeAnnouncementOnce() {
|
||||
const ann = announcement.value
|
||||
if (ann?.id) window.sessionStorage.setItem(`announcement_closed_${ann.id}`, '1')
|
||||
announcementOpen.value = false
|
||||
}
|
||||
|
||||
async function dismissAnnouncementPermanently() {
|
||||
const ann = announcement.value
|
||||
if (!ann?.id) {
|
||||
announcementOpen.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await dismissAnnouncement(ann.id)
|
||||
if (res?.success) ElMessage.success('已永久关闭')
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
announcementOpen.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -96,6 +396,8 @@ async function logout() {
|
||||
({{ userStore.vipDaysLeft }}天后到期)
|
||||
</span>
|
||||
</div>
|
||||
<el-button text type="primary" @click="openFeedbackForm">反馈</el-button>
|
||||
<el-button text @click="openSettings">设置</el-button>
|
||||
<el-button type="primary" plain @click="logout">退出</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
@@ -122,9 +424,191 @@ async function logout() {
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="drawer-actions">
|
||||
<el-button text type="primary" style="width: 100%" @click="openFeedbackForm">问题反馈</el-button>
|
||||
<el-button text style="width: 100%" @click="openSettings">个人设置</el-button>
|
||||
<el-button type="primary" plain style="width: 100%" @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="announcementOpen" width="min(560px, 92vw)" :title="announcement?.title || '系统公告'">
|
||||
<div class="announcement-body" v-loading="announcementLoading">
|
||||
<div class="announcement-content">{{ announcement?.content || '' }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="closeAnnouncementOnce">当次关闭</el-button>
|
||||
<el-button type="primary" @click="dismissAnnouncementPermanently">永久关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="feedbackOpen" title="问题反馈" width="min(720px, 92vw)">
|
||||
<el-tabs v-model="feedbackTab" @tab-change="(name) => name === 'list' && loadMyFeedbacks()">
|
||||
<el-tab-pane label="提交反馈" name="new">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="feedbackForm.title" placeholder="简要描述问题" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="详细描述">
|
||||
<el-input v-model="feedbackForm.description" type="textarea" :rows="5" placeholder="请详细描述您遇到的问题" maxlength="2000" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系方式(可选)">
|
||||
<el-input v-model="feedbackForm.contact" placeholder="方便我们联系您" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="我的反馈" name="list">
|
||||
<el-skeleton v-if="feedbackLoading" :rows="6" animated />
|
||||
<template v-else>
|
||||
<el-empty v-if="myFeedbacks.length === 0" description="暂无反馈" />
|
||||
<el-collapse v-else accordion>
|
||||
<el-collapse-item v-for="item in myFeedbacks" :key="item.id" :name="String(item.id)">
|
||||
<template #title>
|
||||
<div class="feedback-title">
|
||||
<span class="feedback-title-text">{{ item.title }}</span>
|
||||
<el-tag size="small" effect="light" :type="feedbackStatusTagType(item.status)">
|
||||
{{ feedbackStatusLabel(item.status) }}
|
||||
</el-tag>
|
||||
<span class="feedback-time app-muted">{{ item.created_at || '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="feedback-body">
|
||||
<div class="feedback-section">
|
||||
<div class="feedback-label app-muted">描述</div>
|
||||
<div class="feedback-text">{{ item.description }}</div>
|
||||
</div>
|
||||
<div v-if="item.admin_reply" class="feedback-section">
|
||||
<div class="feedback-label app-muted">管理员回复</div>
|
||||
<div class="feedback-text">{{ item.admin_reply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="feedbackOpen = false">关闭</el-button>
|
||||
<el-button v-if="feedbackTab === 'list'" @click="loadMyFeedbacks">刷新</el-button>
|
||||
<el-button v-if="feedbackTab === 'new'" type="primary" :loading="feedbackSubmitting" @click="submitFeedbackForm">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="settingsOpen" title="个人设置" width="min(720px, 92vw)">
|
||||
<el-tabs v-model="settingsTab">
|
||||
<el-tab-pane label="邮箱绑定" name="email">
|
||||
<div v-loading="emailLoading" class="settings-section">
|
||||
<el-alert
|
||||
v-if="emailInfo.email && emailInfo.email_verified"
|
||||
type="success"
|
||||
:closable="false"
|
||||
title="邮箱已绑定并验证"
|
||||
show-icon
|
||||
class="settings-alert"
|
||||
>
|
||||
<template #default>
|
||||
<div class="email-row">
|
||||
<div class="email-value">{{ emailInfo.email }}</div>
|
||||
<el-button type="danger" text @click="onUnbindEmail">解绑</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-alert
|
||||
v-else-if="emailInfo.email"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="邮箱待验证:请查收验证邮件(含垃圾箱)"
|
||||
show-icon
|
||||
class="settings-alert"
|
||||
/>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="邮箱地址">
|
||||
<el-input v-model="bindEmailValue" placeholder="name@example.com" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="bindEmailLoading" @click="onBindEmail">发送验证邮件</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="notify-row">
|
||||
<div>
|
||||
<div class="notify-title">任务完成通知</div>
|
||||
<div class="app-muted notify-desc">定时任务完成后发送邮件</div>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="emailNotifyEnabled"
|
||||
:disabled="!emailInfo.email_verified || emailNotifyLoading"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
@change="onToggleEmailNotify"
|
||||
/>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="!emailInfo.email_verified"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="绑定并验证邮箱后可开启邮件通知。"
|
||||
show-icon
|
||||
class="settings-hint"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="修改密码" name="password">
|
||||
<div class="settings-section">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="当前密码">
|
||||
<el-input v-model="passwordForm.current_password" type="password" show-password autocomplete="current-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码(至少6位)">
|
||||
<el-input v-model="passwordForm.new_password" type="password" show-password autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input
|
||||
v-model="passwordForm.confirm_password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
@keyup.enter="onChangePassword"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="passwordLoading" @click="onChangePassword">确认修改</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="VIP信息" name="vip">
|
||||
<div class="settings-section">
|
||||
<el-alert
|
||||
:type="userStore.isVip ? 'success' : 'info'"
|
||||
:closable="false"
|
||||
:title="userStore.isVip ? '当前为 VIP 会员' : '当前为普通用户'"
|
||||
show-icon
|
||||
class="settings-alert"
|
||||
/>
|
||||
<div v-if="userStore.isVip" class="vip-info">
|
||||
<div class="vip-line">
|
||||
<span class="app-muted">到期时间</span>
|
||||
<span>{{ userStore.vipExpireTime || '未知' }}</span>
|
||||
</div>
|
||||
<div class="vip-line">
|
||||
<span class="app-muted">剩余天数</span>
|
||||
<span>{{ userStore.vipDaysLeft }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="vip-info">
|
||||
<div class="app-muted">升级方式:请通过“反馈”联系管理员开通。</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="settingsOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
@@ -232,6 +716,113 @@ async function logout() {
|
||||
border-top: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.announcement-body {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.feedback-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feedback-title-text {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feedback-time {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
padding: 6px 0 2px;
|
||||
}
|
||||
|
||||
.feedback-section + .feedback-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.feedback-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.feedback-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
padding: 6px 2px 2px;
|
||||
}
|
||||
|
||||
.settings-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.email-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.email-value {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.notify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notify-title {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.notify-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.vip-info {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vip-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout-header {
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user