Skip to content

Commit 60e831d

Browse files
feat: enhance permission directive denied UI
1 parent 2834cd3 commit 60e831d

9 files changed

Lines changed: 566 additions & 18 deletions

File tree

docs/directives/permission.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,50 @@ createPermissionDirective({
104104
})
105105
```
106106

107+
### 展示原文 + 删除线(showOriginal / strikeOriginal)
108+
109+
在常见的“原价划线 + 折扣价”场景下,可以在 `replace` 模式启用“展示原文”能力:
110+
111+
```ts
112+
createPermissionDirective({
113+
rules: {
114+
'product.price': {
115+
whenDenied: {
116+
mode: 'replace',
117+
replaceText: '80',
118+
showOriginal: true,
119+
strikeOriginal: true
120+
}
121+
}
122+
},
123+
resolvePermission: () => false
124+
})
125+
```
126+
127+
> 注意:该能力面向 **文本展示** 的 replace 目标节点;若目标节点包含复杂子树(组件/事件/多层 DOM),替换与恢复会退化为 `textContent` 模型,原子节点结构会丢失。
128+
129+
## 禁用提示(disableTooltip)
130+
131+
`disable` 模式下,可以配置 denied tooltip,在 hover/focus 时展示提示文案:
132+
133+
```ts
134+
createPermissionDirective({
135+
rules: {
136+
'order.submit': {
137+
whenDenied: {
138+
mode: 'disable',
139+
disableTooltip: {
140+
text: '无权限操作'
141+
// class?: 'your-class'
142+
// style?: { maxWidth: '240px' }
143+
}
144+
}
145+
}
146+
},
147+
resolvePermission: () => false
148+
})
149+
```
150+
107151
## 权限等级(byLevel)
108152

109153
当你的权限解析器返回“等级”时(例如 `none/masked/full`),可以按等级分别配置行为:
@@ -148,7 +192,7 @@ createPermissionDirective({
148192
## 行为说明
149193

150194
- `hide`:设置宿主元素 `display: none`
151-
- `disable`:保持可见但不可交互(`cursor: not-allowed` + `aria-disabled="true"`;并在捕获阶段拦截 `click` 事件以阻止默认行为与事件传播;对可禁用表单控件会设置 `disabled=true`
195+
- `disable`:保持可见但不可交互(`cursor: not-allowed` + `aria-disabled="true"`;并在捕获阶段拦截 `click` 事件以阻止默认行为与事件传播;对可禁用表单控件会设置 `disabled=true`;可选 `disableTooltip` 在 hover/focus 时提示原因
152196
- `replace`:仅对宿主元素内部带标识属性的子元素设置 `textContent`
153197
- `allow`:不处理并恢复原始状态
154198

@@ -221,6 +265,15 @@ export type PermissionMode = 'allow' | 'hide' | 'disable' | 'replace'
221265
export interface PermissionBehavior {
222266
mode: PermissionMode
223267
replaceText?: string
268+
269+
disableTooltip?: {
270+
text: string
271+
class?: string
272+
style?: Partial<CSSStyleDeclaration>
273+
}
274+
275+
showOriginal?: boolean
276+
strikeOriginal?: boolean
224277
}
225278
226279
export type PermissionByLevelConfig = Partial<Record<string, PermissionBehavior>>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## Context
2+
`v-permission` 当前支持 `hide/disable/replace/allow`
3+
- `disable`:设置 `cursor`/`aria-disabled`,并阻止 click 默认行为与事件传播。
4+
- `replace`:仅替换宿主元素内部带标识属性的子元素 `textContent`(默认 `data-permission-replace`)。
5+
6+
本变更为 denied 状态增强两类 UI 表达:禁用时提示气泡、替换时可展示原文并划线。
7+
8+
## Goals / Non-Goals
9+
- Goals
10+
- 在不引入外部依赖前提下,为 `disable` 提供可选 tooltip DOM。
11+
-`replace` 提供可选的“原文 + 划线 + 替换文案”渲染。
12+
- 维持现有默认行为与 API 兼容。
13+
- Non-Goals
14+
- 不新增复杂的 Popover 组件、定位引擎、或全局管理器。
15+
- 不支持对任意复杂子树(包含组件/事件绑定)的无损替换;仅面向文本展示场景。
16+
17+
## Decisions
18+
- Tooltip 实现:使用指令在宿主元素上注册 hover/focus 监听,在 `document.body` 中创建/定位一个 tooltip 元素(内容使用 `textContent` 写入),在 restore/unmount 时移除与解绑。
19+
- Tooltip 样式:提供 `class``style` 配置;默认样式使用项目已有 CSS 变量(`--tml-*`)以避免硬编码新颜色/阴影。
20+
- Replace 原文划线:在 replace 目标元素内部,用 DOM 节点组合(原文 span + 分隔符 + 替换 span)进行渲染;恢复时还原为原始文本(保持与既有 `textContent` 替换模型一致)。
21+
22+
## Risks / Trade-offs
23+
- `replace` 目标节点若包含复杂子树(非纯文本),替换与恢复会丢失原子节点结构(与当前 `textContent` 替换模型一致)。文档需强调仅用于文本展示目标。
24+
- Tooltip 需要注意滚动/窗口变化时的位置更新;本变更默认在 show 时计算位置,并在窗口 scroll/resize 时同步更新(或在最小实现下仅 show 时计算,待需求明确)。
25+
26+
## Open Questions
27+
- Tooltip 是否需要支持常显(非 hover/focus)或自定义触发事件。
28+
- Replace “展示原内容”是否需要额外的样式配置(如原文灰色、小号、间距)。
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Change: Enhance v-permission denied UX (tooltip + strike-through)
2+
3+
## Why
4+
当前 `v-permission``disable``replace` 模式缺少更直观的“被拒绝原因/替代展示”能力:
5+
- `disable` 仅禁用交互,无法提示原因/文案。
6+
- `replace` 仅替换目标文本,无法覆盖“原价划线 + 折扣价”这类常见展示。
7+
8+
## What Changes
9+
-`mode: 'disable'` 增加可选的“气泡提示(tooltip)”能力:文本可配置,样式可配置。
10+
-`mode: 'replace'` 增加可选的“展示原内容并划线”能力(用于原价/折扣价展示)。
11+
- 保持现有 `hide/disable/replace/allow` 行为兼容;未配置新选项时行为不变。
12+
13+
## Impact
14+
- Affected capability: `permission-directive`
15+
- Affected code:
16+
- `src/directives/permission/types.ts`
17+
- `src/directives/permission/index.ts`
18+
- `tests/directives/permission.spec.ts`
19+
- `docs/directives/permission.md`
20+
21+
## Notes / Gaps
22+
- 当前 `permission-directive` 的规范仅存在于历史归档变更中(`openspec/changes/archive/2025-12-15-add-permission-directive/...`),未出现在 `openspec/specs/` 列表。本变更将以 delta spec 的形式补齐新增/修改的需求;后续归档阶段应将 capability 纳入 `openspec/specs/`(按项目流程执行)。
23+
24+
## Open Questions
25+
- `disable` 的“气泡”触发方式:默认按 hover/focus 展示是否符合预期?是否需要支持常显?
26+
- `replace` 的“展示原内容”:默认仅作用于带替换标识属性的目标节点(例如 `data-permission-replace`)是否符合预期?
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Configurable modes (hide/disable/replace)
4+
系统 SHALL 支持至少三种权限表现:隐藏、禁用、替换。
5+
6+
#### Scenario: Disable mode (base behavior)
7+
- **GIVEN** 某 key 的规则 mode 为 `disable`
8+
- **WHEN** 当前用户不具备该 key 权限
9+
- **THEN** 指令 SHALL 禁用目标元素(保持可见但不可交互)
10+
- **AND** 指令 SHALL 为目标元素设置 `cursor: not-allowed`
11+
- **AND** 指令 SHALL 阻止点击事件的默认行为与事件传播
12+
13+
#### Scenario: Disable mode with tooltip
14+
- **GIVEN** 某 key 的规则 mode 为 `disable`
15+
- **AND** 该规则配置了禁用提示(tooltip)文本
16+
- **WHEN** 当前用户不具备该 key 权限
17+
- **THEN** 指令 SHALL 在用户 hover 或 focus 目标元素时展示一个气泡提示
18+
- **AND** 气泡提示文本 SHALL 来自配置项
19+
- **AND** 气泡提示样式 SHALL 支持通过配置项自定义
20+
- **AND** 当权限变为允许或指令 unmounted 时,气泡提示相关 DOM 与事件监听 SHALL 被清理
21+
22+
### Requirement: Replace mode only affects marked descendants
23+
系统 SHALL NOT 直接改写宿主元素整体内容;`replace` 模式 SHALL 仅替换宿主元素内部带标识属性的子元素内容。
24+
25+
#### Scenario: Replace mode shows original with strike-through (optional)
26+
- **GIVEN** 某 key 的规则 mode 为 `replace`
27+
- **AND** 目标元素内部存在带有“标识属性”的子元素(默认 `data-permission-replace`
28+
- **AND** 该规则配置了“展示原内容”(showOriginal)
29+
- **WHEN** 当前用户不具备该 key 权限
30+
- **THEN** 指令 SHALL 在被替换的目标子元素内同时展示原内容与替换文案
31+
- **AND** 若配置了“原内容划线”(strikeOriginal),原内容 SHALL 以删除线样式展示
32+
- **AND** 当权限变为允许或指令 updated/unmounted 时,目标子元素 SHALL 恢复原始内容
33+
34+
## ADDED Requirements
35+
36+
### Requirement: Disable tooltip is configurable
37+
系统 SHALL 允许为 `disable` 模式配置提示文案与样式。
38+
39+
#### Scenario: Configure tooltip text and style
40+
- **GIVEN** 使用方在规则中为 `disable` 行为配置 tooltip 文案
41+
- **AND** 使用方可选配置 tooltip 的 class/style
42+
- **WHEN** 指令应用 `disable` 行为
43+
- **THEN** tooltip 文案 SHALL 按配置展示
44+
- **AND** tooltip 样式 SHALL 按配置应用
45+
46+
### Requirement: Replace can optionally preserve original content
47+
系统 SHALL 允许 `replace` 模式在替换目标中保留原内容用于展示。
48+
49+
#### Scenario: Preserve original content for discounted price
50+
- **GIVEN** 使用方为 `replace` 行为配置 showOriginal 与 strikeOriginal
51+
- **WHEN** 当前用户不具备该 key 权限
52+
- **THEN** 替换目标 SHALL 展示“原内容(可选划线) + 替换文案”
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
## 1. Proposal Validation
2+
- [x] 1.1 Run `openspec validate enhance-permission-directive-denied-ui --strict` and fix all issues
3+
4+
## 2. Types & API
5+
- [x] 2.1 Extend `PermissionBehavior` to support disable tooltip configuration
6+
- [x] 2.2 Extend `PermissionBehavior` to support replace "show original + strike" configuration
7+
- [x] 2.3 Ensure backward compatibility for existing `replaceText` usage
8+
9+
## 3. Directive Implementation
10+
- [x] 3.1 Implement tooltip lifecycle for `mode: 'disable'` (bind/unbind, create/remove DOM)
11+
- [x] 3.2 Implement replace rendering for "show original + strike" option
12+
- [x] 3.3 Ensure `restoreAll()` fully restores tooltip/replace states on updates/unmount
13+
14+
## 4. Tests
15+
- [x] 4.1 Add unit tests for disable tooltip creation and cleanup
16+
- [x] 4.2 Add unit tests for replace showOriginal/strikeOriginal rendering and restore
17+
18+
## 5. Docs
19+
- [x] 5.1 Update `docs/directives/permission.md` with new options and examples
20+
- [x] 5.2 Document limitations (replace targets should be text-only)

src/App.vue

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,18 @@
199199
>
200200
{{ hasPermissionKey('order.delete') ? '已授权' : '未授权' }}
201201
</tml-button>
202-
<span class="permission-hint">(whenDenied: disable)</span>
202+
<span class="permission-hint">(whenDenied: disable + disableTooltip)</span>
203+
</div>
204+
205+
<div class="permission-control">
206+
<span class="permission-label">product.discountPrice:</span>
207+
<tml-button
208+
:type="hasPermissionKey('product.discountPrice') ? 'success' : 'warning'"
209+
@click="togglePermissionKey('product.discountPrice')"
210+
>
211+
{{ hasPermissionKey('product.discountPrice') ? '已授权' : '未授权' }}
212+
</tml-button>
213+
<span class="permission-hint">(whenDenied: replace + showOriginal/strikeOriginal)</span>
203214
</div>
204215

205216
<div class="permission-control">
@@ -237,6 +248,18 @@
237248
<span class="permission-hint">(仅替换带 data-permission-replace 的文本)</span>
238249
</p>
239250
</div>
251+
252+
<div class="permission-card" v-permission="'product.discountPrice'">
253+
<p class="permission-row">
254+
<span class="permission-label">商品:</span>
255+
<span>示例商品</span>
256+
</p>
257+
<p class="permission-row">
258+
<span class="permission-label">促销价:</span>
259+
<span data-permission-replace>{{ productOriginalPriceText }}</span>
260+
<span class="permission-hint">(无权限时:原价划线 + 展示替换价)</span>
261+
</p>
262+
</div>
240263
</div>
241264
</section>
242265

@@ -489,6 +512,8 @@ type PricePermissionLevel = 'none' | 'masked' | 'full'
489512
const grantedPermissionKeys = ref<string[]>(['order.create', 'order.delete'])
490513
const pricePermissionLevel = ref<PricePermissionLevel>('none')
491514
const productPriceText = ref('¥ 199.00')
515+
const productOriginalPriceText = ref('¥ 199.00')
516+
const productDiscountPriceText = ref('¥ 159.00')
492517
493518
const hasPermissionKey = (key: string) => grantedPermissionKeys.value.includes(key)
494519
@@ -503,18 +528,13 @@ const togglePermissionKey = (key: string) => {
503528
504529
const vPermission = createPermissionDirective<PricePermissionLevel>({
505530
rules: {
506-
'order.create': {
507-
byLevel: {
508-
none: { mode: 'hide' },
509-
masked: { mode: 'disable' },
510-
full: { mode: 'allow' }
511-
}
512-
},
531+
'order.create': { whenDenied: { mode: 'hide' } },
513532
'order.delete': {
514-
byLevel: {
515-
none: { mode: 'disable' },
516-
masked: { mode: 'disable' },
517-
full: { mode: 'allow' }
533+
whenDenied: {
534+
mode: 'disable',
535+
disableTooltip: {
536+
text: '无权限删除'
537+
}
518538
}
519539
},
520540
'product.price': {
@@ -523,10 +543,19 @@ const vPermission = createPermissionDirective<PricePermissionLevel>({
523543
masked: { mode: 'replace', replaceText: '**.**' },
524544
full: { mode: 'allow' }
525545
}
546+
},
547+
'product.discountPrice': {
548+
whenDenied: {
549+
mode: 'replace',
550+
replaceText: productDiscountPriceText.value,
551+
showOriginal: true,
552+
strikeOriginal: true
553+
}
526554
}
527555
},
528-
resolvePermission: (_key) => {
529-
return pricePermissionLevel.value
556+
resolvePermission: (key) => {
557+
if (key === 'product.price') return pricePermissionLevel.value
558+
return hasPermissionKey(key)
530559
}
531560
})
532561

0 commit comments

Comments
 (0)