n20-project-component 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ <template>
2
+ <div class="n20-batch-input">
3
+ <el-input
4
+ :value="displayValue"
5
+ :placeholder="placeholder"
6
+ :disabled="disabled"
7
+ readonly
8
+ class="n20-batch-input__trigger"
9
+ @click.native="handleOpen"
10
+ >
11
+ <template slot="suffix">
12
+ <span class="n20-batch-input__suffix">
13
+ <i
14
+ v-if="value && !disabled"
15
+ class="el-icon-circle-close n20-batch-input__clear"
16
+ @click.stop="handleClear"
17
+ />
18
+ <i
19
+ class="el-icon-edit-outline n20-batch-input__edit"
20
+ @click.stop="handleOpen"
21
+ />
22
+ </span>
23
+ </template>
24
+ </el-input>
25
+
26
+ <el-dialog
27
+ :title="dialogTitle"
28
+ :visible.sync="dialogVisible"
29
+ width="520px"
30
+ :close-on-click-modal="false"
31
+ @open="handleDialogOpen"
32
+ @closed="handleDialogClosed"
33
+ >
34
+ <div class="n20-batch-input__body">
35
+ <el-input
36
+ v-model="draftText"
37
+ type="textarea"
38
+ :rows="10"
39
+ :placeholder="textareaPlaceholder"
40
+ @input="handleTextareaInput"
41
+ />
42
+ <div
43
+ class="n20-batch-input__counter"
44
+ :class="{ 'is-over': isOverLimit }"
45
+ >
46
+ <span v-if="isOverLimit" class="n20-batch-input__error">
47
+ 已超出最大条数 {{ max }},请删减后提交
48
+ </span>
49
+ <span class="n20-batch-input__count">
50
+ 当前 {{ parsedItems.length }} 条 / 最大 {{ max }} 条
51
+ </span>
52
+ </div>
53
+ </div>
54
+ <div slot="footer">
55
+ <el-button @click="dialogVisible = false">取 消</el-button>
56
+ <el-button
57
+ type="primary"
58
+ :disabled="isOverLimit"
59
+ @click="handleConfirm"
60
+ >
61
+ 确 认
62
+ </el-button>
63
+ </div>
64
+ </el-dialog>
65
+ </div>
66
+ </template>
67
+
68
+ <script>
69
+ /**
70
+ * N20BatchInput - 批量输入组件
71
+ *
72
+ * 点击输入框打开弹窗,支持从 Excel 粘贴内容,自动按换行/制表符/逗号拆分为多个条目。
73
+ * v-model 绑定逗号分隔的字符串。
74
+ *
75
+ * @example
76
+ * <N20BatchInput v-model="batchValue" :max="50" />
77
+ */
78
+ export default {
79
+ name: 'N20BatchInput',
80
+ model: {
81
+ prop: 'value',
82
+ event: 'input',
83
+ },
84
+ props: {
85
+ /** v-model 绑定值,逗号分隔字符串 */
86
+ value: {
87
+ type: String,
88
+ default: '',
89
+ },
90
+ /** 最大条数限制 */
91
+ max: {
92
+ type: Number,
93
+ default: 100,
94
+ },
95
+ /** 输入框占位符 */
96
+ placeholder: {
97
+ type: String,
98
+ default: '请输入',
99
+ },
100
+ /** 弹窗标题 */
101
+ dialogTitle: {
102
+ type: String,
103
+ default: '批量输入',
104
+ },
105
+ /** 文本域占位符 */
106
+ textareaPlaceholder: {
107
+ type: String,
108
+ default: '支持从 Excel 粘贴,每行 / 逗号 / Tab 自动识别为一条',
109
+ },
110
+ /** 是否禁用 */
111
+ disabled: {
112
+ type: Boolean,
113
+ default: false,
114
+ },
115
+ },
116
+ data() {
117
+ return {
118
+ dialogVisible: false,
119
+ draftText: '',
120
+ parsedItems: [],
121
+ }
122
+ },
123
+ computed: {
124
+ displayValue() {
125
+ return this.value || ''
126
+ },
127
+ isOverLimit() {
128
+ return this.parsedItems.length > this.max
129
+ },
130
+ },
131
+ methods: {
132
+ handleOpen() {
133
+ if (this.disabled) return
134
+ this.dialogVisible = true
135
+ },
136
+ handleDialogOpen() {
137
+ // 将当前 value 还原为每行一条
138
+ if (this.value) {
139
+ const items = this.value.split(',').map(s => s.trim()).filter(Boolean)
140
+ this.draftText = items.join('\n')
141
+ this.parsedItems = items
142
+ } else {
143
+ this.draftText = ''
144
+ this.parsedItems = []
145
+ }
146
+ },
147
+ handleDialogClosed() {
148
+ this.draftText = ''
149
+ this.parsedItems = []
150
+ },
151
+ handleTextareaInput(val) {
152
+ const items = val
153
+ .split(/[\n\r\t,,]+/)
154
+ .map(s => s.trim())
155
+ .filter(Boolean)
156
+ this.parsedItems = items
157
+ },
158
+ handleConfirm() {
159
+ if (this.isOverLimit) return
160
+ const result = this.parsedItems.join(',')
161
+ this.$emit('input', result)
162
+ this.$emit('change', result)
163
+ this.dialogVisible = false
164
+ },
165
+ handleClear() {
166
+ this.$emit('input', '')
167
+ this.$emit('change', '')
168
+ },
169
+ },
170
+ }
171
+ </script>
172
+
173
+ <style scoped>
174
+ .n20-batch-input {
175
+ display: inline-block;
176
+ width: 100%;
177
+ }
178
+
179
+ .n20-batch-input__trigger {
180
+ cursor: pointer;
181
+ }
182
+
183
+ .n20-batch-input__trigger /deep/ .el-input__inner {
184
+ cursor: pointer;
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ white-space: nowrap;
188
+ padding-right: 52px;
189
+ }
190
+
191
+ .n20-batch-input__suffix {
192
+ display: inline-flex;
193
+ align-items: center;
194
+ height: 100%;
195
+ gap: 4px;
196
+ }
197
+
198
+ .n20-batch-input__clear:hover {
199
+ color: #909399;
200
+ }
201
+
202
+ .n20-batch-input__edit {
203
+ color: #c0c4cc;
204
+ cursor: pointer;
205
+ }
206
+
207
+ .n20-batch-input__edit:hover {
208
+ color: #409eff;
209
+ }
210
+
211
+ .n20-batch-input__body {
212
+ padding: 0 4px;
213
+ }
214
+
215
+ .n20-batch-input__counter {
216
+ display: flex;
217
+ justify-content: flex-end;
218
+ align-items: center;
219
+ margin-top: 8px;
220
+ font-size: 13px;
221
+ color: #909399;
222
+ gap: 12px;
223
+ }
224
+
225
+ .n20-batch-input__counter.is-over {
226
+ color: #f56c6c;
227
+ }
228
+
229
+ .n20-batch-input__error {
230
+ flex: 1;
231
+ color: #f56c6c;
232
+ }
233
+
234
+ .n20-batch-input__count {
235
+ white-space: nowrap;
236
+ }
237
+ </style>
@@ -0,0 +1,318 @@
1
+ <template>
2
+ <div
3
+ class="n20-floating-toolbar-wrapper"
4
+ ref="wrapper"
5
+ @mouseup="handleMouseUp"
6
+ >
7
+ <!-- 包裹的内容区域 -->
8
+ <slot></slot>
9
+
10
+ <!-- 浮现工具条 -->
11
+ <transition name="n20-toolbar-fade">
12
+ <div
13
+ v-if="visible"
14
+ ref="toolbar"
15
+ class="n20-floating-toolbar"
16
+ :style="toolbarStyle"
17
+ @mousedown.prevent
18
+ >
19
+ <div class="n20-floating-toolbar__arrow"></div>
20
+ <div class="n20-floating-toolbar__body">
21
+ <button
22
+ v-for="(btn, idx) in actions"
23
+ :key="idx"
24
+ class="n20-floating-toolbar__btn"
25
+ :title="btn.label"
26
+ @click.stop="handleAction(btn, idx)"
27
+ >
28
+ <i v-if="btn.icon" :class="btn.icon"></i>
29
+ <span v-if="btn.label && showLabel">{{ btn.label }}</span>
30
+ </button>
31
+ <!-- 允许通过 slot 自定义工具栏内容 -->
32
+ <slot name="toolbar" :selectedText="selectedText"></slot>
33
+ </div>
34
+ </div>
35
+ </transition>
36
+ </div>
37
+ </template>
38
+
39
+ <script>
40
+ /**
41
+ * N20FloatingToolbar — 选中浮现工具栏
42
+ *
43
+ * 包裹内容区域,用户选中文字后在选区上方浮现操作按钮条。
44
+ * 鼠标拖选文字松开后,选区上方浮现深色工具条,
45
+ * 点击按钮触发事件,点其他地方消失。
46
+ *
47
+ * @example
48
+ * <N20FloatingToolbar
49
+ * :actions="[
50
+ * { label: '复制', icon: 'el-icon-document-copy' },
51
+ * { label: '搜索', icon: 'el-icon-search' },
52
+ * { label: '高亮', icon: 'el-icon-edit' },
53
+ * ]"
54
+ * @action="handleToolbarAction"
55
+ * >
56
+ * <p>这里是一段可以被选中的文本内容...</p>
57
+ * </N20FloatingToolbar>
58
+ */
59
+ export default {
60
+ name: 'N20FloatingToolbar',
61
+ props: {
62
+ /**
63
+ * 工具栏按钮列表
64
+ * 每项 { label: string, icon?: string, command?: string }
65
+ */
66
+ actions: {
67
+ type: Array,
68
+ default: () => [
69
+ { label: '复制', icon: 'el-icon-document-copy', command: 'copy' },
70
+ { label: '搜索', icon: 'el-icon-search', command: 'search' },
71
+ { label: '高亮', icon: 'el-icon-edit', command: 'highlight' },
72
+ ]
73
+ },
74
+ /** 是否在按钮中显示文字标签 */
75
+ showLabel: {
76
+ type: Boolean,
77
+ default: true
78
+ },
79
+ /** 工具栏与选区的垂直间距(px) */
80
+ offset: {
81
+ type: Number,
82
+ default: 8
83
+ },
84
+ /** 是否禁用 */
85
+ disabled: {
86
+ type: Boolean,
87
+ default: false
88
+ },
89
+ /** 选中最少字符数才触发 */
90
+ minLength: {
91
+ type: Number,
92
+ default: 1
93
+ }
94
+ },
95
+ data() {
96
+ return {
97
+ visible: false,
98
+ selectedText: '',
99
+ toolbarStyle: {
100
+ top: '0px',
101
+ left: '0px'
102
+ }
103
+ }
104
+ },
105
+ mounted() {
106
+ // 监听全局点击 → 隐藏工具栏
107
+ this._onDocClick = (e) => {
108
+ if (this.visible && this.$refs.toolbar && !this.$refs.toolbar.contains(e.target)) {
109
+ this.hide()
110
+ }
111
+ }
112
+ // 监听滚动 → 隐藏工具栏
113
+ this._onScroll = () => {
114
+ if (this.visible) {
115
+ this.hide()
116
+ }
117
+ }
118
+ document.addEventListener('mousedown', this._onDocClick, true)
119
+ window.addEventListener('scroll', this._onScroll, true)
120
+ },
121
+ beforeDestroy() {
122
+ document.removeEventListener('mousedown', this._onDocClick, true)
123
+ window.removeEventListener('scroll', this._onScroll, true)
124
+ },
125
+ methods: {
126
+ /** 鼠标松开时判断是否有选区 */
127
+ handleMouseUp() {
128
+ if (this.disabled) return
129
+
130
+ // 延迟一帧让浏览器完成选区更新
131
+ this.$nextTick(() => {
132
+ setTimeout(() => {
133
+ this.checkSelection()
134
+ }, 0)
135
+ })
136
+ },
137
+
138
+ /** 检测当前选区 */
139
+ checkSelection() {
140
+ const selection = window.getSelection()
141
+ if (!selection || selection.isCollapsed || !selection.toString().trim()) {
142
+ this.hide()
143
+ return
144
+ }
145
+
146
+ const text = selection.toString().trim()
147
+ if (text.length < this.minLength) {
148
+ this.hide()
149
+ return
150
+ }
151
+
152
+ // 确保选区在 wrapper 内
153
+ const wrapper = this.$refs.wrapper
154
+ if (!wrapper) return
155
+
156
+ const range = selection.getRangeAt(0)
157
+ if (!wrapper.contains(range.commonAncestorContainer)) {
158
+ this.hide()
159
+ return
160
+ }
161
+
162
+ this.selectedText = text
163
+ this.positionToolbar(range)
164
+ this.visible = true
165
+ },
166
+
167
+ /** 根据选区 Range 定位工具栏 */
168
+ positionToolbar(range) {
169
+ const rect = range.getBoundingClientRect()
170
+ const wrapperRect = this.$refs.wrapper.getBoundingClientRect()
171
+
172
+ // 工具栏居中于选区上方
173
+ const left = rect.left + rect.width / 2 - wrapperRect.left
174
+ const top = rect.top - wrapperRect.top - this.offset
175
+
176
+ this.toolbarStyle = {
177
+ left: `${left}px`,
178
+ top: `${top}px`
179
+ }
180
+ },
181
+
182
+ /** 点击按钮 */
183
+ handleAction(btn, idx) {
184
+ this.$emit('action', {
185
+ command: btn.command || btn.label,
186
+ label: btn.label,
187
+ index: idx,
188
+ text: this.selectedText,
189
+ })
190
+
191
+ // 内置 copy 行为
192
+ if (btn.command === 'copy') {
193
+ this.doCopy(this.selectedText)
194
+ }
195
+
196
+ this.hide()
197
+ },
198
+
199
+ /** 复制文本到剪贴板 */
200
+ async doCopy(text) {
201
+ try {
202
+ if (navigator.clipboard && window.isSecureContext) {
203
+ await navigator.clipboard.writeText(text)
204
+ } else {
205
+ const textarea = document.createElement('textarea')
206
+ textarea.value = text
207
+ textarea.style.position = 'fixed'
208
+ textarea.style.left = '-9999px'
209
+ document.body.appendChild(textarea)
210
+ textarea.select()
211
+ document.execCommand('copy')
212
+ document.body.removeChild(textarea)
213
+ }
214
+ this.$message && this.$message.success('已复制')
215
+ } catch (err) {
216
+ this.$message && this.$message.error('复制失败')
217
+ }
218
+ },
219
+
220
+ /** 隐藏工具栏 */
221
+ hide() {
222
+ this.visible = false
223
+ this.selectedText = ''
224
+ },
225
+
226
+ /** 外部可调用:手动显示 */
227
+ show() {
228
+ this.checkSelection()
229
+ }
230
+ }
231
+ }
232
+ </script>
233
+
234
+ <style lang="scss" scoped>
235
+ .n20-floating-toolbar-wrapper {
236
+ position: relative;
237
+ display: inline-block;
238
+ width: 100%;
239
+ }
240
+
241
+ .n20-floating-toolbar {
242
+ position: absolute;
243
+ z-index: 9999;
244
+ transform: translateX(-50%) translateY(-100%);
245
+ pointer-events: auto;
246
+
247
+ // 箭头
248
+ &__arrow {
249
+ position: absolute;
250
+ left: 50%;
251
+ bottom: -5px;
252
+ transform: translateX(-50%) rotate(45deg);
253
+ width: 10px;
254
+ height: 10px;
255
+ background: #1f2937;
256
+ border-radius: 0 0 2px 0;
257
+ }
258
+
259
+ // 主体
260
+ &__body {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ gap: 2px;
264
+ padding: 4px 6px;
265
+ background: #1f2937;
266
+ border-radius: 6px;
267
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
268
+ white-space: nowrap;
269
+ }
270
+
271
+ // 按钮
272
+ &__btn {
273
+ display: inline-flex;
274
+ align-items: center;
275
+ gap: 4px;
276
+ padding: 5px 10px;
277
+ border: none;
278
+ border-radius: 4px;
279
+ background: transparent;
280
+ color: #e5e7eb;
281
+ font-size: 13px;
282
+ line-height: 1;
283
+ cursor: pointer;
284
+ transition: background 0.15s ease, color 0.15s ease;
285
+ outline: none;
286
+
287
+ i {
288
+ font-size: 14px;
289
+ }
290
+
291
+ &:hover {
292
+ background: rgba(255, 255, 255, 0.12);
293
+ color: #fff;
294
+ }
295
+
296
+ &:active {
297
+ background: rgba(255, 255, 255, 0.18);
298
+ }
299
+
300
+ & + & {
301
+ margin-left: 1px;
302
+ }
303
+ }
304
+ }
305
+
306
+ // 出现动画
307
+ .n20-toolbar-fade-enter-active {
308
+ transition: opacity 0.18s ease, transform 0.18s ease;
309
+ }
310
+ .n20-toolbar-fade-leave-active {
311
+ transition: opacity 0.12s ease, transform 0.12s ease;
312
+ }
313
+ .n20-toolbar-fade-enter,
314
+ .n20-toolbar-fade-leave-to {
315
+ opacity: 0;
316
+ transform: translateX(-50%) translateY(-100%) scale(0.92);
317
+ }
318
+ </style>
package/src/index.js CHANGED
@@ -6,17 +6,23 @@
6
6
  // ========== 组件导入 ==========
7
7
  import DemoButton from './components/DemoButton/index.vue'
8
8
  import N20CopyText from './components/N20CopyText/index.vue'
9
+ import N20FloatingToolbar from './components/N20FloatingToolbar/index.vue'
10
+ import N20BatchInput from './components/N20BatchInput/index.vue'
9
11
 
10
12
  // ========== 组件列表 ==========
11
13
  const components = {
12
14
  DemoButton,
13
15
  N20CopyText,
16
+ N20FloatingToolbar,
17
+ N20BatchInput,
14
18
  }
15
19
 
16
20
  // ========== 按需导出 ==========
17
21
  export {
18
22
  DemoButton,
19
23
  N20CopyText,
24
+ N20FloatingToolbar,
25
+ N20BatchInput,
20
26
  }
21
27
 
22
28
  // ========== 全量注册(Vue.use)==========