feat: v0.1.1 - poll editing, notifications, fixes
- Poll editing by creator (title, options, deadline) - Notification panel with app notifications and click-to-navigate - Poll creation notifies group members - Invitation rejection notifies inviter - Fix: notification createRule, timezone, autocancel, nginx, tab timing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
|
||||||
|
collection.options = {
|
||||||
|
"allowEmailAuth": true,
|
||||||
|
"allowOAuth2Auth": true,
|
||||||
|
"allowUsernameAuth": true,
|
||||||
|
"exceptEmailDomains": null,
|
||||||
|
"manageRule": null,
|
||||||
|
"minPasswordLength": 6,
|
||||||
|
"onlyEmailDomains": null,
|
||||||
|
"onlyVerified": false,
|
||||||
|
"requireEmail": false
|
||||||
|
}
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
|
||||||
|
collection.options = {
|
||||||
|
"allowEmailAuth": true,
|
||||||
|
"allowOAuth2Auth": true,
|
||||||
|
"allowUsernameAuth": true,
|
||||||
|
"exceptEmailDomains": null,
|
||||||
|
"manageRule": null,
|
||||||
|
"minPasswordLength": 8,
|
||||||
|
"onlyEmailDomains": null,
|
||||||
|
"onlyVerified": false,
|
||||||
|
"requireEmail": false
|
||||||
|
}
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
|
||||||
|
collection.listRule = ""
|
||||||
|
collection.viewRule = ""
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
|
||||||
|
collection.listRule = "@request.auth.id != \"\""
|
||||||
|
collection.viewRule = "@request.auth.id != \"\""
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
|
||||||
|
collection.listRule = ""
|
||||||
|
collection.viewRule = ""
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
|
||||||
|
collection.listRule = "@request.auth.id != \"\""
|
||||||
|
collection.viewRule = "@request.auth.id != \"\""
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
})
|
||||||
@@ -106,7 +106,7 @@ migrate((db) => {
|
|||||||
"indexes": [],
|
"indexes": [],
|
||||||
"listRule": "user = @request.auth.id",
|
"listRule": "user = @request.auth.id",
|
||||||
"viewRule": "user = @request.auth.id",
|
"viewRule": "user = @request.auth.id",
|
||||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
"createRule": "@request.auth.id != \"\"",
|
||||||
"updateRule": "user = @request.auth.id",
|
"updateRule": "user = @request.auth.id",
|
||||||
"deleteRule": "user = @request.auth.id",
|
"deleteRule": "user = @request.auth.id",
|
||||||
"options": {}
|
"options": {}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu")
|
||||||
|
|
||||||
|
collection.createRule = "@request.auth.id != \"\""
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
}, (db) => {
|
||||||
|
const dao = new Dao(db)
|
||||||
|
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu")
|
||||||
|
|
||||||
|
collection.createRule = "@request.auth.id != \"\" && user = @request.auth.id"
|
||||||
|
|
||||||
|
return dao.saveCollection(collection)
|
||||||
|
})
|
||||||
@@ -111,6 +111,27 @@ export async function respondInvitation(
|
|||||||
|
|
||||||
// 更新邀请状态
|
// 更新邀请状态
|
||||||
await pb.collection('invitations').update(invitationId, updateData)
|
await pb.collection('invitations').update(invitationId, updateData)
|
||||||
|
|
||||||
|
// 通知邀请发起人
|
||||||
|
try {
|
||||||
|
const invitation = await pb.collection('invitations').getOne(invitationId, {
|
||||||
|
expand: 'teamSession',
|
||||||
|
$autoCancel: false,
|
||||||
|
}) as any
|
||||||
|
const { createNotification } = await import('./notifications')
|
||||||
|
await createNotification({
|
||||||
|
user: invitation.from,
|
||||||
|
type: response === 'rejected' ? 'team_invite' : 'team_invite',
|
||||||
|
title: response === 'rejected' ? '邀请被拒绝' : '邀请已接受',
|
||||||
|
content: response === 'rejected'
|
||||||
|
? (rejectReason || '对方拒绝了组队邀请')
|
||||||
|
: '对方已接受组队邀请',
|
||||||
|
relatedId: invitation.teamSession,
|
||||||
|
relatedType: 'team',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 通知失败不影响主流程
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订阅邀请变更
|
// 订阅邀请变更
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { pb } from './pocketbase'
|
import { pb } from './pocketbase'
|
||||||
import { awardPoints, deductPoints } from './points'
|
import { awardPoints, deductPoints } from './points'
|
||||||
|
import { createNotification } from './notifications'
|
||||||
import type { Poll, PollOption, PollVote } from '@/types'
|
import type { Poll, PollOption, PollVote } from '@/types'
|
||||||
|
|
||||||
export async function createPoll(data: {
|
export async function createPoll(data: {
|
||||||
@@ -33,6 +34,27 @@ export async function createPoll(data: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 给同群组其他成员发送通知
|
||||||
|
try {
|
||||||
|
const group = await pb.collection('groups').getOne(data.group)
|
||||||
|
const typeLabel = data.type === 'rollcall' ? '接龙报名' : '投票'
|
||||||
|
const otherMembers = (group.members || []).filter((id: string) => id !== user.id)
|
||||||
|
await Promise.all(
|
||||||
|
otherMembers.map((memberId: string) =>
|
||||||
|
createNotification({
|
||||||
|
user: memberId,
|
||||||
|
type: 'poll_new',
|
||||||
|
title: `新${typeLabel}`,
|
||||||
|
content: `${data.title}`,
|
||||||
|
relatedId: poll.id,
|
||||||
|
relatedType: 'poll',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// 通知发送失败不影响主流程
|
||||||
|
}
|
||||||
|
|
||||||
return poll as unknown as Poll
|
return poll as unknown as Poll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useNotificationStore } from '@/stores/notification'
|
import { useNotificationStore } from '@/stores/notification'
|
||||||
import InvitationCard from '@/components/team/InvitationCard.vue'
|
import InvitationCard from '@/components/team/InvitationCard.vue'
|
||||||
import JoinRequestCard from '@/components/group/JoinRequestCard.vue'
|
import JoinRequestCard from '@/components/group/JoinRequestCard.vue'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
|
||||||
const store = useNotificationStore()
|
const store = useNotificationStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.loadPendingInvitations()
|
store.loadPendingInvitations()
|
||||||
|
store.loadAppNotifications()
|
||||||
})
|
})
|
||||||
|
|
||||||
function onInvitationResponded(id: string, _accepted: boolean) {
|
function onInvitationResponded(id: string, _accepted: boolean) {
|
||||||
@@ -17,6 +21,27 @@ function onInvitationResponded(id: string, _accepted: boolean) {
|
|||||||
function onJoinRequestResponded(id: string) {
|
function onJoinRequestResponded(id: string) {
|
||||||
store.removeJoinRequest(id)
|
store.removeJoinRequest(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleNotificationClick(n: { id: string; read: boolean; relatedType?: string; relatedId?: string }) {
|
||||||
|
if (!n.read) store.markRead(n.id)
|
||||||
|
|
||||||
|
if (n.relatedType === 'poll' && n.relatedId) {
|
||||||
|
try {
|
||||||
|
const poll = await pb.collection('polls').getOne(n.relatedId, { $autoCancel: false })
|
||||||
|
store.showPanel = false
|
||||||
|
router.push({ name: 'GroupView', params: { id: poll.group }, query: { tab: 'polls' } })
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else if (n.relatedType === 'team' && n.relatedId) {
|
||||||
|
try {
|
||||||
|
const session = await pb.collection('team_sessions').getOne(n.relatedId, { $autoCancel: false })
|
||||||
|
store.showPanel = false
|
||||||
|
router.push({ name: 'GroupView', params: { id: session.sourceGroup } })
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else if (n.relatedType === 'group' && n.relatedId) {
|
||||||
|
store.showPanel = false
|
||||||
|
router.push({ name: 'GroupView', params: { id: n.relatedId } })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -56,6 +81,29 @@ function onJoinRequestResponded(id: string) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 站内通知 -->
|
||||||
|
<div v-if="store.appNotifications.length > 0" class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4 class="section-title">消息通知</h4>
|
||||||
|
<button v-if="store.appUnreadCount > 0" class="mark-all-btn" @click="store.markAllRead">
|
||||||
|
全部已读
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="list">
|
||||||
|
<div
|
||||||
|
v-for="n in store.appNotifications"
|
||||||
|
:key="n.id"
|
||||||
|
class="app-notification"
|
||||||
|
:class="{ unread: !n.read }"
|
||||||
|
@click="handleNotificationClick(n)"
|
||||||
|
>
|
||||||
|
<div class="notif-title">{{ n.title }}</div>
|
||||||
|
<div v-if="n.content" class="notif-content">{{ n.content }}</div>
|
||||||
|
<div class="notif-time">{{ new Date(n.created).toLocaleString('zh-CN') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@@ -76,11 +124,30 @@ function onJoinRequestResponded(id: string) {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--gg-text-secondary);
|
color: var(--gg-text-secondary);
|
||||||
margin: 0 0 10px;
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
@@ -88,4 +155,39 @@ function onJoinRequestResponded(id: string) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-notification {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-notification:hover {
|
||||||
|
background: var(--gg-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-notification.unread {
|
||||||
|
border-left: 3px solid var(--gg-primary);
|
||||||
|
background: rgba(5, 150, 105, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
import { createPoll } from '@/api/polls'
|
import { createPoll } from '@/api/polls'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
|
||||||
@@ -166,14 +167,13 @@ function handleOpen() {
|
|||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<button
|
||||||
v-if="canAddOption"
|
v-if="canAddOption"
|
||||||
type="primary"
|
class="add-option-btn"
|
||||||
text
|
|
||||||
@click="addOption"
|
@click="addOption"
|
||||||
>
|
>
|
||||||
+ 添加选项
|
<el-icon><Plus /></el-icon> 添加选项
|
||||||
</el-button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,9 +208,9 @@ function handleOpen() {
|
|||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="visible = false">取消</el-button>
|
<el-button @click="visible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||||
创建
|
{{ loading ? '创建中...' : '创建' }}
|
||||||
</el-button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -281,4 +281,44 @@ function handleOpen() {
|
|||||||
.option-item .el-input {
|
.option-item .el-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-option-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-option-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ interface LogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logs = ref<LogEntry[]>([
|
const logs = ref<LogEntry[]>([
|
||||||
|
{
|
||||||
|
version: 'v0.1.1',
|
||||||
|
date: '2026-04-18',
|
||||||
|
title: '二期功能优化',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '投票编辑:发起人可修改标题、选项、截止时间,已投票选项不可删除' },
|
||||||
|
{ type: 'feat', text: '通知面板展示站内消息通知,支持投票/组队/群组通知跳转' },
|
||||||
|
{ type: 'feat', text: '创建投票后自动通知同群组成员' },
|
||||||
|
{ type: 'feat', text: '邀请被拒绝/接受后通知邀请发起人' },
|
||||||
|
{ type: 'feat', text: '通知点击跳转到对应群组并切换到相关 Tab' },
|
||||||
|
{ type: 'fix', text: '修复通知 createRule 权限限制导致无法给他人发送通知' },
|
||||||
|
{ type: 'fix', text: '修复截止时间时区偏差,统一 ISO 格式存储' },
|
||||||
|
{ type: 'fix', text: '修复投票列表 auto-cancel 竞态和选项 order=0 校验失败' },
|
||||||
|
{ type: 'fix', text: '修复 nginx 静态缓存误拦截 API 文件请求和上传大小限制' },
|
||||||
|
{ type: 'fix', text: '修复回忆 Tab 图片不展示和视频无法播放问题' },
|
||||||
|
{ type: 'fix', text: '修复切换 Tab 数据不加载的时序问题' },
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: 'v0.1.0',
|
version: 'v0.1.0',
|
||||||
date: '2026-04-18',
|
date: '2026-04-18',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const route = useRoute()
|
|||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const teamStore = useTeamStore()
|
const teamStore = useTeamStore()
|
||||||
|
|
||||||
const activeTab = ref('activity')
|
const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : 'activity')
|
||||||
const viewingPollId = ref<string | null>(null)
|
const viewingPollId = ref<string | null>(null)
|
||||||
|
|
||||||
const unsubFns: (() => Promise<void>)[] = []
|
const unsubFns: (() => Promise<void>)[] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user