hookehuyr

refactor(permission): 统一权限检查并移除重复代码

- 移除 material-list 页面中重复的 usePermission 导入和调用
- 权限检查完全由 ListItemActions 组件内部处理
- 简化 onView 函数直接调用 handleFileClick
- 添加 ListItemActions 组件 README 文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 +## [2026-02-13] - 统一权限检查与移除重复代码
2 +
3 +### 新增
4 +- ListItemActions 组件集成权限检查逻辑
5 +
6 +### 优化
7 +- 移除 material-list 页面中重复的 usePermission 调用
8 +- 权限检查完全由 ListItemActions 组件内部处理
9 +- 添加 ListItemActions 组件 README 文档
10 +
11 +---
12 +
13 +**详细信息**
14 +- **影响文件**: src/pages/material-list/index.vue, src/components/list/ListItemActions/index.vue, src/components/list/ListItemActions/README.md
15 +- **技术栈**: Vue 3, Taro 4
16 +- **测试状态**: 已通过
17 +- **备注**: 组件自包含业务逻辑模式,父组件无需重复权限检查
18 +
19 +---
20 +
21 +## [2026-02-13] - 资料查看权限与搜索页测试对齐
22 +
23 +### 新增
24 +- 统一动作级权限映射,支持页面查看权限扩展
25 +- 资料查看入口增加登录权限校验与回跳路径记录
26 +
27 +### 优化
28 +- 搜索页测试对齐当前实现并补充接口 Mock
29 +
30 +---
31 +
32 +**详细信息**
33 +- **影响文件**: src/composables/usePermission.js, src/config/permissions.js, src/components/cards/MaterialCard.vue, src/pages/material-list/index.vue, src/pages/search/index.test.js, vitest.config.js, package.json
34 +- **技术栈**: Vue 3, Taro 4, Pinia, Vitest
35 +- **测试状态**: 已通过(pnpm test)
36 +- **备注**: lint 存在历史 warning 未处理
37 +
38 +---
39 +
1 ## [2026-02-13] - 我的页面消息红点显示 40 ## [2026-02-13] - 我的页面消息红点显示
2 41
3 ### 新增 42 ### 新增
......
1 +# ListItemActions 组件使用指南
2 +
3 +## 📋 组件概述
4 +
5 +**组件名称**: `ListItemActions`
6 +**文件路径**: `src/components/list/ListItemActions/index.vue`
7 +**用途**: 列表项的操作按钮组件,支持查看、收藏、删除三种操作
8 +
9 +---
10 +
11 +## ✨ 功能特性
12 +
13 +### 1. 按钮类型
14 +
15 +| 按钮 | 说明 | 依赖属性 |
16 +|------|------|----------|
17 +| **查看** | 查看文件/产品详情 | `viewable` |
18 +| **收藏** | 切换收藏状态 | `collectable` |
19 +| **删除** | 删除列表项 | `deletable` |
20 +
21 +### 2. 内置功能
22 +
23 +-**权限检查**: 自动检查登录权限(通过 `usePermission`
24 +-**埋点上报**: 权限通过后自动发送查看埋点(通过 `useEventTracking`
25 +-**状态管理**: 收藏按钮根据 `collected` 状态切换样式
26 +
27 +---
28 +
29 +## 🎯 使用方法
30 +
31 +### 基础用法
32 +
33 +```vue
34 +<template>
35 + <ListItemActions
36 + :viewable="true"
37 + :collectable="true"
38 + :deletable="false"
39 + :collected="item.collected"
40 + :item-id="item.id"
41 + @view="handleView"
42 + @collect="handleCollect"
43 + @delete="handleDelete"
44 + />
45 +</template>
46 +
47 +<script setup>
48 +const handleView = () => {
49 + // 权限检查通过后自动触发
50 + // 不需要手动检查登录状态
51 + console.log('查看文件')
52 +}
53 +
54 +const handleCollect = () => {
55 + // 收藏状态由组件内部管理
56 + // 父组件可以监听 collectChanged 事件
57 + console.log('切换收藏')
58 +}
59 +
60 +const handleDelete = () => {
61 + // 触发删除操作
62 + console.log('删除项目')
63 +}
64 +</script>
65 +```
66 +
67 +---
68 +
69 +## 📝 Props 说明
70 +
71 +| 属性 | 类型 | 默认值 | 说明 |
72 +|------|------|---------|------|
73 +| `viewable` | Boolean | `true` | 是否显示查看按钮 |
74 +| `collectable` | Boolean | `false` | 是否显示收藏按钮 |
75 +| `deletable` | Boolean | `false` | 是否显示删除按钮 |
76 +| `collected` | Boolean | `false` | 是否已收藏(影响收藏按钮样式) |
77 +| `itemId` | String | `''` | 关联对象 ID,用于埋点上报 |
78 +
79 +### 使用示例
80 +
81 +```vue
82 +<!-- 只显示查看和删除 -->
83 +<ListItemActions
84 + :viewable="true"
85 + :deletable="true"
86 + :item-id="doc123"
87 + @view="onView"
88 + @delete="onDelete"
89 +/>
90 +
91 +<!-- 三个按钮都显示 -->
92 +<ListItemActions
93 + :viewable="true"
94 + :collectable="true"
95 + :deletable="true"
96 + :collected="true"
97 + :item-id="product456"
98 + @view="onView"
99 + @collect="onCollect"
100 + @delete="onDelete"
101 +/>
102 +```
103 +
104 +---
105 +
106 +## 📤 事件说明
107 +
108 +### @view
109 +
110 +**触发时机**: 权限检查通过后
111 +
112 +**注意**: 如果用户未登录,会先显示登录弹框,登录成功后才会触发此事件
113 +
114 +```vue
115 +<script setup>
116 +const handleView = (itemData) => {
117 + console.log('执行查看逻辑:', itemData)
118 + // 这里调用实际的文件打开逻辑
119 +}
120 +</script>
121 +
122 +<template>
123 + <ListItemActions @view="handleView" />
124 +</template>
125 +```
126 +
127 +### @collect
128 +
129 +**触发时机**: 点击收藏按钮时立即触发
130 +
131 +```vue
132 +<script setup>
133 +const handleCollect = () => {
134 + // 切换收藏状态
135 + // 实际的收藏状态由 collected 属性控制
136 +}
137 +</script>
138 +
139 +<template>
140 + <ListItemActions @collect="handleCollect" />
141 +</template>
142 +```
143 +
144 +### @delete
145 +
146 +**触发时机**: 点击删除按钮时立即触发
147 +
148 +```vue
149 +<script setup>
150 +const handleDelete = () => {
151 + // 执行删除操作
152 + // 调用删除 API 等
153 +}
154 +</script>
155 +
156 +<template>
157 + <ListItemActions @delete="handleDelete" />
158 +</template>
159 +```
160 +
161 +---
162 +
163 +## 🔐 权限和埋点流程
164 +
165 +### 查看按钮完整流程
166 +
167 +```
168 +用户点击"查看"按钮
169 +
170 +组件内部: requireAction('view_material', callback)
171 +
172 +┌─ 未登录 ──────────────────────────────┐
173 +│ │
174 +│ 1. 检查登录状态 │
175 +│ 2. 显示登录弹框 │
176 +│ 3. ❌ 不发送埋点 │
177 +│ 4. ❌ 不触发 @view 事件 │
178 +│ │
179 +└─────────────────────────────────────────┘
180 +
181 +┌─ 已登录 ──────────────────────────────┐
182 +│ │
183 +│ 1. 权限检查通过 │
184 +│ 2. ✅ 发送埋点 (trackFileRead) │
185 +│ 3. ✅ 触发 @view 事件 │
186 +│ │
187 +└─────────────────────────────────────────┘
188 +
189 +父组件收到 @view 事件
190 +
191 +执行实际业务逻辑(打开文件等)
192 +```
193 +
194 +### 权限配置
195 +
196 +权限配置位于 `src/config/permissions.js`
197 +
198 +```javascript
199 +export const ACTION_PERMISSIONS = {
200 + view_material: {
201 + permission_type: PermissionType.LOGIN,
202 + options: {
203 + content: '请先登录后查看资料',
204 + confirmText: '去登录'
205 + }
206 + }
207 + }
208 +}
209 +```
210 +
211 +### 埋点配置
212 +
213 +埋点配置位于 `src/composables/useEventTracking.js`
214 +
215 +- **事件类型**: `READ_FILE`
216 +- **上报时机**: 权限检查通过后立即上报
217 +- **上报数据**: `{ type: 'READ_FILE', object_id: itemId }`
218 +
219 +---
220 +
221 +## 🎨 样式说明
222 +
223 +### TailwindCSS 类名
224 +
225 +| 类名 | 作用 |
226 +|------|------|
227 +| `flex justify-end gap-[24rpx]` | 右对齐,24rpx 间距 |
228 +| `flex items-center` | 垂直居中 |
229 +| `text-blue-600` | 蓝色文字(查看按钮) |
230 +| `text-red-500` | 红色文字(删除按钮) |
231 +| `text-red-500` / `text-gray-400` | 收藏按钮动态颜色 |
232 +| `text-[24rpx]` | 字体大小 |
233 +
234 +### 自定义样式
235 +
236 +如果需要覆盖默认样式,可以在父组件中使用:
237 +
238 +```vue
239 +<template>
240 + <ListItemActions
241 + class="custom-actions"
242 + @view="handleView"
243 + />
244 +</template>
245 +
246 +<style scoped>
247 +.custom-actions :deep(.text-blue-600) {
248 + color: #custom-color;
249 +}
250 +</style>
251 +```
252 +
253 +---
254 +
255 +## ⚠️ 注意事项
256 +
257 +### 1. 权限检查是自动的
258 +
259 +不需要手动检查登录状态,组件内部已处理:
260 +
261 +```vue
262 +<!-- ❌ 错误:手动检查权限 -->
263 +<script setup>
264 +import { usePermission } from '@/composables/usePermission'
265 +
266 +const { isLoggedIn } = usePermission()
267 +
268 +const handleView = () => {
269 + if (!isLoggedIn()) {
270 + showToast('请先登录')
271 + return
272 + }
273 + // 继续逻辑
274 +}
275 +</script>
276 +
277 +<!-- ✅ 正确:让组件处理 -->
278 +<script setup>
279 +const handleView = () => {
280 + // 直接执行,权限检查在组件内部
281 +}
282 +</script>
283 +```
284 +
285 +### 2. 埋点需要 itemId
286 +
287 +如果不上报埋点,确保传递了 `item-id` 属性:
288 +
289 +```vue
290 +<!-- ❌ 缺少 itemId,埋点不会上报 -->
291 +<ListItemActions @view="handleView" />
292 +
293 +<!-- ✅ 正确:提供 itemId -->
294 +<ListItemActions :item-id="'123456'" @view="handleView" />
295 +```
296 +
297 +### 3. 父组件的 @view 事件参数
298 +
299 +`@view` 事件目前不传递参数,如果需要文件信息,请:
300 +
301 +1. 在父组件中维护数据对象引用
302 +2. 或者扩展组件支持传递完整 item 数据
303 +
304 +```vue
305 +<!-- 父组件示例 -->
306 +<script setup>
307 +const currentItem = ref(null)
308 +
309 +const handleView = () => {
310 + // currentItem 已在组件内部设置
311 + console.log('查看:', currentItem.value)
312 +}
313 +</script>
314 +
315 +<template>
316 + <ListItemActions @view="handleView" />
317 +</template>
318 +```
319 +
320 +---
321 +
322 +## 🔧 扩展组件
323 +
324 +### 添加新的按钮类型
325 +
326 +如果需要添加新的操作按钮(如"分享"、"下载"等):
327 +
328 +1.`props` 中添加新的控制属性
329 +2. 在模板中添加按钮 UI
330 +3. 添加对应的 emit 和 handler
331 +
332 +```javascript
333 +// 示例:添加分享按钮
334 +const props = defineProps({
335 + // ... 现有属性
336 + shareable: {
337 + type: Boolean,
338 + default: false
339 + }
340 +})
341 +
342 +const emit = defineEmits({
343 + // ... 现有事件
344 + share: null
345 +})
346 +
347 +const handleShare = () => {
348 + emit('share')
349 +}
350 +```
351 +
352 +---
353 +
354 +## 📚 相关文件
355 +
356 +- **权限配置**: `src/config/permissions.js`
357 +- **权限 Composable**: `src/composables/usePermission.js`
358 +- **埋点 Composable**: `src/composables/useEventTracking.js`
359 +- **收藏操作**: `src/composables/useCollectOperation.js`
...@@ -40,6 +40,7 @@ ...@@ -40,6 +40,7 @@
40 import { computed } from 'vue' 40 import { computed } from 'vue'
41 import IconFont from '@/components/icons/IconFont.vue' 41 import IconFont from '@/components/icons/IconFont.vue'
42 import { useEventTracking } from '@/composables/useEventTracking' 42 import { useEventTracking } from '@/composables/useEventTracking'
43 +import { usePermission } from '@/composables/usePermission'
43 44
44 /** 45 /**
45 * 组件属性 46 * 组件属性
...@@ -114,18 +115,23 @@ const isCollected = computed(() => props.collected) ...@@ -114,18 +115,23 @@ const isCollected = computed(() => props.collected)
114 // 初始化埋点功能 115 // 初始化埋点功能
115 const { trackFileRead } = useEventTracking() 116 const { trackFileRead } = useEventTracking()
116 117
118 +// 初始化权限检查
119 +const { requireAction } = usePermission()
120 +
117 /** 121 /**
118 * 处理查看点击 122 * 处理查看点击
119 * 123 *
120 - * @description 触发查看事件,并自动发送埋点数据 124 + * @description 先检查权限,通过后发送埋点并通知父组件
121 */ 125 */
122 const handleView = () => { 126 const handleView = () => {
123 - // 如果提供了 itemId,自动发送埋点 127 + requireAction('view_material', () => {
128 + // 权限检查通过后,发送埋点
124 if (props.itemId) { 129 if (props.itemId) {
125 trackFileRead(props.itemId) 130 trackFileRead(props.itemId)
126 } 131 }
127 - 132 + // 通知父组件执行实际业务逻辑
128 emit('view') 133 emit('view')
134 + })
129 } 135 }
130 136
131 /** 137 /**
......
1 +/**
2 + * 通用权限检查 Composable
3 + *
4 + * @description 提供统一的权限检查逻辑,支持登录、VIP 等多种权限类型
5 + * @module composables/usePermission
6 + * @author Claude Code
7 + * @created 2026-02-13
8 + *
9 + * @example
10 + * // 基础使用 - 检查登录权限
11 + * const { requireLogin } = usePermission()
12 + *
13 + * requireLogin(() => {
14 + * // 已登录时执行的操作
15 + * openDocument()
16 + * })
17 + *
18 + * @example
19 + * // 高级使用 - 自定义提示文案
20 + * const { checkPermission } = usePermission()
21 + *
22 + * checkPermission(PermissionType.LOGIN, callback, {
23 + * content: '请先登录后查看完整内容',
24 + * confirmText: '立即登录'
25 + * })
26 + */
27 +
28 +import { useUserStore } from '@/stores/user'
29 +import Taro from '@tarojs/taro'
30 +import { routerStore } from '@/stores/router'
31 +import { PermissionType, getPermissionConfig, getActionPermissionConfig } from '@/config/permissions'
32 +
33 +/**
34 + * 通用权限检查 Hook
35 + *
36 + * @description 提供权限检查、权限弹窗等功能
37 + * @returns {Object} 权限检查方法集合
38 + */
39 +export function usePermission() {
40 + const userStore = useUserStore()
41 +
42 + /**
43 + * 检查登录状态
44 + * @description 判断用户是否已登录
45 + * @returns {boolean} 是否已登录
46 + *
47 + * @example
48 + * const { isLoggedIn } = usePermission()
49 + * if (isLoggedIn()) {
50 + * console.log('用户已登录')
51 + * }
52 + */
53 + const isLoggedIn = () => {
54 + return userStore.isLoggedIn
55 + }
56 +
57 + const getCurrentPageUrl = () => {
58 + const pages = Taro.getCurrentPages()
59 + const currentPage = pages[pages.length - 1]
60 + if (!currentPage || !currentPage.route) {
61 + return ''
62 + }
63 + const options = currentPage.options || {}
64 + const query = Object.keys(options)
65 + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(options[key])}`)
66 + .join('&')
67 + return `/${currentPage.route}${query ? `?${query}` : ''}`
68 + }
69 +
70 + /**
71 + * 通用权限检查
72 + * @description 检查指定权限,无权限时弹窗提示,有权限时执行回调
73 + * @param {string} permissionType - 权限类型(使用 PermissionType 枚举)
74 + * @param {Function} callback - 有权限时执行的回调函数
75 + * @param {Object} customOptions - 自定义配置选项(会覆盖默认配置)
76 + * @returns {boolean} 是否有权限(true=有权限,false=无权限)
77 + *
78 + * @example
79 + * // 使用默认配置
80 + * checkPermission(PermissionType.LOGIN, () => {
81 + * console.log('已登录,执行操作')
82 + * })
83 + *
84 + * @example
85 + * // 自定义提示文案
86 + * checkPermission(PermissionType.LOGIN, callback, {
87 + * content: '请先登录后查看完整内容',
88 + * confirmText: '立即登录'
89 + * })
90 + */
91 + const checkPermission = (permissionType, callback, customOptions = {}) => {
92 + console.log(`[usePermission] 检查权限: ${permissionType}`)
93 +
94 + // 根据权限类型执行不同的检查逻辑
95 + switch (permissionType) {
96 + case PermissionType.LOGIN:
97 + return checkLoginPermission(callback, customOptions)
98 +
99 + case PermissionType.VIP:
100 + return checkVipPermission(callback, customOptions)
101 +
102 + case PermissionType.VERIFIED:
103 + return checkVerifiedPermission(callback, customOptions)
104 +
105 + default:
106 + console.warn(`[usePermission] 未知的权限类型: ${permissionType}`)
107 + return false
108 + }
109 + }
110 +
111 + /**
112 + * 检查登录权限
113 + * @description 判断用户是否登录,未登录时弹窗引导
114 + * @param {Function} callback - 已登录时执行的回调
115 + * @param {Object} customOptions - 自定义配置选项
116 + * @returns {boolean} 是否已登录
117 + * @private
118 + */
119 + const checkLoginPermission = (callback, customOptions = {}) => {
120 + if (isLoggedIn()) {
121 + // 已登录,直接执行回调
122 + console.log('[usePermission] 用户已登录,执行回调')
123 + callback?.()
124 + return true
125 + }
126 +
127 + // 未登录,弹窗提示
128 + console.log('[usePermission] 用户未登录,显示登录提示')
129 + showLoginModal(callback, customOptions)
130 + return false
131 + }
132 +
133 + /**
134 + * 检查 VIP 权限(预留)
135 + * @description 检查用户是否为 VIP,非 VIP 时弹窗引导
136 + * @param {Function} callback - 有权限时执行的回调
137 + * @param {Object} customOptions - 自定义配置选项
138 + * @returns {boolean} 是否有权限
139 + * @private
140 + */
141 + const checkVipPermission = (callback, customOptions = {}) => {
142 + // 预留:检查 VIP 状态
143 + // const isVip = userStore.userInfo?.is_vip
144 + const isVip = true // 暂时返回 true
145 +
146 + if (isVip) {
147 + callback?.()
148 + return true
149 + }
150 +
151 + showPermissionModal(PermissionType.VIP, callback, customOptions)
152 + return false
153 + }
154 +
155 + /**
156 + * 检查实名认证权限(预留)
157 + * @description 检查用户是否实名认证,未认证时弹窗引导
158 + * @param {Function} callback - 有权限时执行的回调
159 + * @param {Object} customOptions - 自定义配置选项
160 + * @returns {boolean} 是否有权限
161 + * @private
162 + */
163 + const checkVerifiedPermission = (callback, customOptions = {}) => {
164 + // 预留:检查实名认证状态
165 + // const isVerified = userStore.userInfo?.is_verified
166 + const isVerified = true // 暂时返回 true
167 +
168 + if (isVerified) {
169 + callback?.()
170 + return true
171 + }
172 +
173 + showPermissionModal(PermissionType.VERIFIED, callback, customOptions)
174 + return false
175 + }
176 +
177 + /**
178 + * 显示登录弹窗
179 + * @description 显示登录引导弹窗,点击确定跳转登录页
180 + * @param {Function} callback - 用户登录成功后希望执行的回调(用于登录后返回)
181 + * @param {Object} customOptions - 自定义配置选项
182 + * @private
183 + */
184 + const showLoginModal = (callback, customOptions = {}) => {
185 + const config = getPermissionConfig(PermissionType.LOGIN, customOptions)
186 +
187 + Taro.showModal({
188 + title: config.title,
189 + content: config.content,
190 + confirmText: config.confirmText,
191 + cancelText: config.cancelText,
192 + success: (res) => {
193 + if (res.confirm) {
194 + // 用户点击"去登录"
195 + console.log('[usePermission] 用户选择去登录')
196 + const store = routerStore()
197 + const currentUrl = getCurrentPageUrl()
198 + if (currentUrl) {
199 + store.add(currentUrl)
200 + }
201 + goToLoginPage()
202 + } else {
203 + // 用户点击"暂不登录"
204 + console.log('[usePermission] 用户取消登录')
205 + }
206 + }
207 + })
208 + }
209 +
210 + /**
211 + * 显示权限提示弹窗(通用)
212 + * @description 显示权限不足的提示弹窗
213 + * @param {string} permissionType - 权限类型
214 + * @param {Function} callback - 有权限时执行的回调
215 + * @param {Object} customOptions - 自定义配置选项
216 + * @private
217 + */
218 + const showPermissionModal = (permissionType, callback, customOptions = {}) => {
219 + const config = getPermissionConfig(permissionType, customOptions)
220 +
221 + Taro.showModal({
222 + title: config.title,
223 + content: config.content,
224 + confirmText: config.confirmText,
225 + cancelText: config.cancelText,
226 + success: (res) => {
227 + if (res.confirm) {
228 + console.log(`[usePermission] 用户确认权限提示: ${permissionType}`)
229 + // 根据权限类型执行不同操作
230 + handlePermissionAction(permissionType)
231 + }
232 + }
233 + })
234 + }
235 +
236 + /**
237 + * 处理权限确认后的操作
238 + * @description 根据权限类型跳转到对应页面
239 + * @param {string} permissionType - 权限类型
240 + * @private
241 + */
242 + const handlePermissionAction = (permissionType) => {
243 + switch (permissionType) {
244 + case PermissionType.LOGIN:
245 + goToLoginPage()
246 + break
247 +
248 + case PermissionType.VIP:
249 + // 跳转到 VIP 开通页面
250 + console.log('[usePermission] 跳转到 VIP 开通页面')
251 + break
252 +
253 + case PermissionType.VERIFIED:
254 + // 跳转到实名认证页面
255 + console.log('[usePermission] 跳转到实名认证页面')
256 + break
257 + }
258 + }
259 +
260 + /**
261 + * 跳转到登录页
262 + * @description 跳转到登录页面
263 + * @private
264 + */
265 + const goToLoginPage = () => {
266 + Taro.navigateTo({
267 + url: '/pages/login/index'
268 + })
269 + }
270 +
271 + /**
272 + * 便捷方法:要求登录权限
273 + * @description 专门用于检查登录权限的便捷方法
274 + * @param {Function} callback - 已登录时执行的回调
275 + * @param {Object} customOptions - 自定义配置选项
276 + * @returns {boolean} 是否已登录
277 + *
278 + * @example
279 + * const { requireLogin } = usePermission()
280 + *
281 + * // 查看资料需要登录
282 + * onView(item) {
283 + * requireLogin(() => {
284 + * openDocument(item.url)
285 + * })
286 + * }
287 + */
288 + const requireLogin = (callback, customOptions = {}) => {
289 + return checkPermission(PermissionType.LOGIN, callback, customOptions)
290 + }
291 +
292 + const requireAction = (action, callback, customOptions = {}) => {
293 + const actionConfig = getActionPermissionConfig(action, customOptions)
294 + return checkPermission(actionConfig.permission_type, callback, actionConfig.options)
295 + }
296 +
297 + /**
298 + * 便捷方法:静默检查权限(不弹窗)
299 + * @description 只检查权限状态,不弹窗提示
300 + * @param {string} permissionType - 权限类型
301 + * @returns {boolean} 是否有权限
302 + *
303 + * @example
304 + * const { hasPermission } = usePermission()
305 + *
306 + * if (hasPermission(PermissionType.LOGIN)) {
307 + * console.log('用户已登录')
308 + * }
309 + */
310 + const hasPermission = (permissionType) => {
311 + switch (permissionType) {
312 + case PermissionType.LOGIN:
313 + return isLoggedIn()
314 + case PermissionType.VIP:
315 + return true // 暂时返回 true
316 + case PermissionType.VERIFIED:
317 + return true // 暂时返回 true
318 + default:
319 + return false
320 + }
321 + }
322 +
323 + // ========== 返回 ==========
324 + return {
325 + /** 核心方法:通用权限检查 */
326 + checkPermission,
327 +
328 + /** 便捷方法:检查登录权限(最常用) */
329 + requireLogin,
330 +
331 + requireAction,
332 +
333 + /** 工具方法:静默检查是否有权限(不弹窗) */
334 + hasPermission,
335 +
336 + /** 工具方法:获取当前登录状态 */
337 + isLoggedIn
338 + }
339 +}
1 +import { describe, it, expect, vi, beforeEach } from 'vitest'
2 +import { usePermission } from './usePermission'
3 +import { getActionPermissionConfig } from '@/config/permissions'
4 +
5 +const show_modal_mock = vi.fn()
6 +const navigate_to_mock = vi.fn()
7 +const get_current_pages_mock = vi.fn()
8 +const add_mock = vi.fn()
9 +let user_state = { isLoggedIn: false }
10 +
11 +vi.mock('@tarojs/taro', () => ({
12 + default: {
13 + showModal: (...args) => show_modal_mock(...args),
14 + navigateTo: (...args) => navigate_to_mock(...args),
15 + getCurrentPages: () => get_current_pages_mock()
16 + }
17 +}))
18 +
19 +vi.mock('@/stores/user', () => ({
20 + useUserStore: () => user_state
21 +}))
22 +
23 +vi.mock('@/stores/router', () => ({
24 + routerStore: () => ({
25 + add: add_mock
26 + })
27 +}))
28 +
29 +describe('usePermission', () => {
30 + beforeEach(() => {
31 + show_modal_mock.mockReset()
32 + navigate_to_mock.mockReset()
33 + get_current_pages_mock.mockReset()
34 + add_mock.mockReset()
35 + user_state = { isLoggedIn: false }
36 + })
37 +
38 + it('requireAction 登录状态下执行回调', () => {
39 + user_state.isLoggedIn = true
40 + const callback = vi.fn()
41 + const { requireAction } = usePermission()
42 + const result = requireAction('view_material', callback)
43 + expect(result).toBe(true)
44 + expect(callback).toHaveBeenCalledTimes(1)
45 + expect(show_modal_mock).not.toHaveBeenCalled()
46 + })
47 +
48 + it('requireAction 未登录时保存回跳并跳转登录', () => {
49 + get_current_pages_mock.mockReturnValue([
50 + { route: 'pages/material-list/index', options: { id: '1', title: '资料' } }
51 + ])
52 + show_modal_mock.mockImplementation((options) => {
53 + options.success({ confirm: true })
54 + })
55 + const { requireAction } = usePermission()
56 + const result = requireAction('view_material', () => {})
57 + expect(result).toBe(false)
58 + expect(show_modal_mock).toHaveBeenCalledTimes(1)
59 + expect(add_mock).toHaveBeenCalledWith('/pages/material-list/index?id=1&title=%E8%B5%84%E6%96%99')
60 + expect(navigate_to_mock).toHaveBeenCalledWith({ url: '/pages/login/index' })
61 + })
62 +})
63 +
64 +describe('getActionPermissionConfig', () => {
65 + it('未知 action 返回默认登录配置', () => {
66 + const result = getActionPermissionConfig('unknown_action', { content: 'x' })
67 + expect(result.permission_type).toBe('login')
68 + expect(result.options.content).toBe('x')
69 + })
70 +})
1 /** 1 /**
2 - * 计划书权限检查 Composable 2 + * 计划书权限检查 Composable(重构版)
3 * 3 *
4 - * @description 统一处理制作计划书的登录权限检查 4 + * @description 统一处理制作计划书的登录权限检查,内部调用通用 usePermission
5 * @module composables/usePlanPermission 5 * @module composables/usePlanPermission
6 * @author Claude Code 6 * @author Claude Code
7 * @created 2026-02-12 7 * @created 2026-02-12
8 + * @updated 2026-02-13 - 重构为使用通用 usePermission
8 */ 9 */
9 10
10 -import { useUserStore } from '@/stores/user' 11 +import { usePermission } from '@/composables/usePermission'
11 -import Taro from '@tarojs/taro'
12 12
13 /** 13 /**
14 * 计划书权限检查 Hook 14 * 计划书权限检查 Hook
...@@ -26,48 +26,37 @@ import Taro from '@tarojs/taro' ...@@ -26,48 +26,37 @@ import Taro from '@tarojs/taro'
26 * }) 26 * })
27 */ 27 */
28 export function usePlanPermission() { 28 export function usePlanPermission() {
29 - const userStore = useUserStore() 29 + // 获取通用权限检查方法
30 + const { requireLogin } = usePermission()
30 31
31 /** 32 /**
32 * 检查计划书权限 33 * 检查计划书权限
33 * 34 *
34 * @description 判断用户是否登录,未登录时提示并引导登录,已登录时执行回调 35 * @description 判断用户是否登录,未登录时提示并引导登录,已登录时执行回调
35 * @param {Function} callback - 已登录时执行的回调函数 36 * @param {Function} callback - 已登录时执行的回调函数
37 + * @param {Object} customOptions - 自定义配置选项(可选)
36 * @returns {boolean} 是否有权限(true=已登录,false=未登录) 38 * @returns {boolean} 是否有权限(true=已登录,false=未登录)
37 * 39 *
38 * @example 40 * @example
41 + * // 使用默认配置
39 * const hasPermission = checkPlanPermission(() => { 42 * const hasPermission = checkPlanPermission(() => {
40 * console.log('用户已登录,可以制作计划书') 43 * console.log('用户已登录,可以制作计划书')
41 * }) 44 * })
45 + *
46 + * @example
47 + * // 自定义提示文案
48 + * const hasPermission = checkPlanPermission(() => {
49 + * openPlanPopup(productId)
50 + * }, {
51 + * content: '请先登录后制作专属计划书',
52 + * confirmText: '立即登录'
53 + * })
42 */ 54 */
43 - const checkPlanPermission = (callback) => { 55 + const checkPlanPermission = (callback, customOptions = {}) => {
44 - console.log('[usePlanPermission] 检查权限,当前登录状态:', userStore.isLoggedIn) 56 + console.log('[usePlanPermission] 检查计划书权限')
45 - // 检查登录状态
46 - if (!userStore.isLoggedIn) {
47 - console.log('[usePlanPermission] 用户未登录,显示登录提示')
48 - // 未登录,显示提示框
49 - Taro.showModal({
50 - title: '提示',
51 - content: '请先登录后再制作计划书',
52 - confirmText: '去登录',
53 - cancelText: '取消',
54 - success: (res) => {
55 - if (res.confirm) {
56 - // 用户点击"去登录",跳转到登录页
57 - console.log('[usePlanPermission] 用户点击去登录')
58 - Taro.navigateTo({
59 - url: '/pages/login/index'
60 - })
61 - }
62 - }
63 - })
64 - return false
65 - }
66 57
67 - console.log('[usePlanPermission] 用户已登录,执行回调') 58 + // 调用通用权限检查(登录权限)
68 - // 已登录,执行回调 59 + return requireLogin(callback, customOptions)
69 - callback?.()
70 - return true
71 } 60 }
72 61
73 return { 62 return {
......
1 +/**
2 + * 权限配置中心
3 + *
4 + * @description 统一管理应用中的权限类型和提示文案
5 + * @module config/permissions
6 + * @author Claude Code
7 + * @created 2026-02-13
8 + */
9 +
10 +/**
11 + * 权限类型枚举
12 + * @description 定义应用中的各种权限类型
13 + * @enum {string}
14 + *
15 + * @example
16 + * import { PermissionType } from '@/config/permissions'
17 + *
18 + * if (needLogin) {
19 + * checkPermission(PermissionType.LOGIN, callback)
20 + * }
21 + */
22 +export const PermissionType = {
23 + /** 登录权限 */
24 + LOGIN: 'login',
25 + /** VIP 权限(预留) */
26 + VIP: 'vip',
27 + /** 实名认证权限(预留) */
28 + VERIFIED: 'verified'
29 +}
30 +
31 +/**
32 + * 权限提示文案配置
33 + * @description 为每种权限类型配置对应的提示文案
34 + *
35 + * @example
36 + * import { PermissionMessages } from '@/config/permissions'
37 + *
38 + * const message = PermissionMessages[PermissionType.LOGIN]
39 + * console.log(message.content) // "登录后即可查看完整内容"
40 + */
41 +export const PermissionMessages = {
42 + /** 登录权限提示 */
43 + [PermissionType.LOGIN]: {
44 + /** 弹窗标题 */
45 + title: '温馨提示',
46 + /** 弹窗内容 */
47 + content: '登录后即可查看完整内容',
48 + /** 确认按钮文案 */
49 + confirmText: '去登录',
50 + /** 取消按钮文案 */
51 + cancelText: '暂不登录'
52 + },
53 +
54 + /** VIP 权限提示(预留) */
55 + [PermissionType.VIP]: {
56 + title: 'VIP 专享',
57 + content: '此功能仅对 VIP 用户开放',
58 + confirmText: '开通 VIP',
59 + cancelText: '取消'
60 + },
61 +
62 + /** 实名认证权限提示(预留) */
63 + [PermissionType.VERIFIED]: {
64 + title: '需要实名认证',
65 + content: '请先完成实名认证',
66 + confirmText: '去认证',
67 + cancelText: '取消'
68 + }
69 +}
70 +
71 +export const ActionPermissionMap = {
72 + view_material: {
73 + permission_type: PermissionType.LOGIN,
74 + options: {}
75 + }
76 +}
77 +
78 +/**
79 + * 获取权限提示配置
80 + * @description 根据权限类型获取对应的提示配置,支持自定义覆盖
81 + * @param {string} permissionType - 权限类型
82 + * @param {Object} customOptions - 自定义配置选项
83 + * @returns {Object} 合并后的权限配置
84 + *
85 + * @example
86 + * const config = getPermissionConfig(PermissionType.LOGIN, {
87 + * content: '请先登录后查看资料详情'
88 + * })
89 + */
90 +export function getPermissionConfig(permissionType, customOptions = {}) {
91 + const defaultConfig = PermissionMessages[permissionType]
92 +
93 + if (!defaultConfig) {
94 + console.warn(`[Permission] 未找到权限类型配置: ${permissionType}`)
95 + return {
96 + title: '提示',
97 + content: '您没有相应的权限',
98 + confirmText: '确定',
99 + cancelText: '取消'
100 + }
101 + }
102 +
103 + return {
104 + ...defaultConfig,
105 + ...customOptions
106 + }
107 +}
108 +
109 +export function getActionPermissionConfig(action, customOptions = {}) {
110 + const actionConfig = ActionPermissionMap[action] || {
111 + permission_type: PermissionType.LOGIN,
112 + options: {}
113 + }
114 +
115 + return {
116 + permission_type: actionConfig.permission_type,
117 + options: {
118 + ...actionConfig.options,
119 + ...customOptions
120 + }
121 + }
122 +}
...@@ -676,7 +676,7 @@ const onClear = async () => { ...@@ -676,7 +676,7 @@ const onClear = async () => {
676 * - 视频文件:自动跳转到视频播放页面 676 * - 视频文件:自动跳转到视频播放页面
677 * - 其他文件:自动下载并使用 Taro.openDocument 打开 677 * - 其他文件:自动下载并使用 Taro.openDocument 打开
678 */ 678 */
679 -const { handleClick: onView } = useListItemClick({ 679 +const { handleClick: handleFileClick } = useListItemClick({
680 listType: ListType.FILE, 680 listType: ListType.FILE,
681 onAfterClick: (item) => { 681 onAfterClick: (item) => {
682 console.log('[Material List] 用户打开了资料:', item.title) 682 console.log('[Material List] 用户打开了资料:', item.title)
...@@ -684,6 +684,17 @@ const { handleClick: onView } = useListItemClick({ ...@@ -684,6 +684,17 @@ const { handleClick: onView } = useListItemClick({
684 }) 684 })
685 685
686 /** 686 /**
687 + * 查看资料
688 + * @description 点击查看按钮时,直接触发文件预览
689 + * @param {Object} item - 资料对象
690 + *
691 + * @note 权限检查已在 ListItemActions 组件内部统一处理
692 + */
693 +const onView = (item) => {
694 + handleFileClick(item)
695 +}
696 +
697 +/**
687 * 切换收藏状态 698 * 切换收藏状态
688 * @description 使用 useCollectOperation composable 处理收藏操作 699 * @description 使用 useCollectOperation composable 处理收藏操作
689 */ 700 */
......
1 +import { defineConfig } from 'vitest/config'
2 +import vue from '@vitejs/plugin-vue'
3 +import { fileURLToPath } from 'url'
4 +import { dirname, resolve } from 'path'
5 +
6 +const __filename = fileURLToPath(import.meta.url)
7 +const __dirname = dirname(__filename)
8 +
9 +const ignoreCssPlugin = () => ({
10 + name: 'ignore-css',
11 + enforce: 'pre',
12 + resolveId(id) {
13 + if (id.endsWith('.css')) {
14 + return id
15 + }
16 + return null
17 + },
18 + load(id) {
19 + if (id.endsWith('.css')) {
20 + return ''
21 + }
22 + return null
23 + }
24 +})
25 +
26 +export default defineConfig({
27 + plugins: [ignoreCssPlugin(), vue()],
28 + test: {
29 + environment: 'happy-dom',
30 + css: true,
31 + deps: {
32 + inline: ['@nutui/icons-vue-taro']
33 + }
34 + },
35 + resolve: {
36 + alias: {
37 + '@': resolve(__dirname, 'src'),
38 + '@nutui/icons-vue-taro': resolve(__dirname, 'node_modules/@nutui/icons-vue-taro/dist/es/index.es.js')
39 + },
40 + extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
41 + }
42 +})