@veltra/sheet 2.5.4 → 2.5.6

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.
@@ -25,6 +25,18 @@ function resolveImageType(file) {
25
25
  const ext = file.name.includes(".") ? file.name.slice(file.name.lastIndexOf(".") + 1) : "";
26
26
  return EXT_TO_TYPE[ext.toLowerCase()];
27
27
  }
28
+ /** 取当前活动格锚点;无选区时 message.error 提示并返回 undefined */
29
+ function activeCellAnchor(ctx) {
30
+ const active = ctx.getSelection().activeCell;
31
+ if (!active) {
32
+ message.error("请先选择单元格");
33
+ return;
34
+ }
35
+ return { from: {
36
+ row: active.row,
37
+ col: active.col
38
+ } };
39
+ }
28
40
  /**
29
41
  * 从本地 File 插入浮动图片:读字节 → 映射类型 → 锚定活动格 → ctx.insertImage。
30
42
  * 不支持的格式 / 无活动格时 message.error 提示并返回 undefined。
@@ -35,19 +47,42 @@ async function insertImageFromFile(ctx, file) {
35
47
  message.error("不支持的图片格式,请选择 png / jpeg / gif / svg / webp");
36
48
  return;
37
49
  }
38
- const active = ctx.getSelection().activeCell;
39
- if (!active) {
40
- message.error("请先选择单元格");
41
- return;
42
- }
50
+ const anchor = activeCellAnchor(ctx);
51
+ if (!anchor) return void 0;
43
52
  const buffer = await file.arrayBuffer();
44
53
  return ctx.insertImage({
45
54
  data: new Uint8Array(buffer),
46
55
  type,
47
- anchor: { from: {
48
- row: active.row,
49
- col: active.col
50
- } }
56
+ anchor
57
+ });
58
+ }
59
+ /**
60
+ * 从 URL 插入浮动图片:URL 直存 src(渲染层直接引用),data 为空字节、
61
+ * type 仅作提示(取 URL 路径扩展名,未知按 png)。锚定活动格,经 ctx.insertImage 写入。
62
+ * 空串 / 非法 URL / 无活动格时 message.error 提示并返回 undefined。
63
+ */
64
+ function insertImageFromUrl(ctx, url) {
65
+ const src = url.trim();
66
+ if (!src) {
67
+ message.error("请输入图片 URL");
68
+ return;
69
+ }
70
+ let pathname;
71
+ try {
72
+ pathname = new URL(src).pathname;
73
+ } catch {
74
+ message.error("请输入有效的图片 URL");
75
+ return;
76
+ }
77
+ const anchor = activeCellAnchor(ctx);
78
+ if (!anchor) return void 0;
79
+ const ext = pathname.includes(".") ? pathname.slice(pathname.lastIndexOf(".") + 1) : "";
80
+ const type = EXT_TO_TYPE[ext.toLowerCase()] ?? "png";
81
+ return ctx.insertImage({
82
+ data: /* @__PURE__ */ new Uint8Array(),
83
+ type,
84
+ src,
85
+ anchor
51
86
  });
52
87
  }
53
88
  /**
@@ -76,4 +111,4 @@ function pickAndInsertImage(ctx) {
76
111
  input.click();
77
112
  }
78
113
  //#endregion
79
- export { INSERT_IMAGE_ACCEPT, insertImageFromFile, pickAndInsertImage, resolveImageType };
114
+ export { INSERT_IMAGE_ACCEPT, insertImageFromFile, insertImageFromUrl, pickAndInsertImage, resolveImageType };
@@ -1,6 +1,6 @@
1
- import { INSERT_IMAGE_ACCEPT, insertImageFromFile } from "../insert-image.js";
2
- import { createBlock, createElementVNode, createTextVNode, defineComponent, normalizeClass, openBlock, unref, withCtx } from "vue";
3
- import { UFilePicker } from "@veltra/desktop";
1
+ import { INSERT_IMAGE_ACCEPT, insertImageFromFile, insertImageFromUrl } from "../insert-image.js";
2
+ import { Fragment, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, normalizeClass, openBlock, ref, unref, withCtx, withKeys } from "vue";
3
+ import { UButton, UFilePicker, UInput } from "@veltra/desktop";
4
4
  import { bem } from "@veltra/utils";
5
5
  //#region src/components/sheet/popups/insert-image-popup.vue
6
6
  const _sfc_main = /*@__PURE__*/ defineComponent({
@@ -10,27 +10,48 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
10
10
  emits: ["close"],
11
11
  setup(__props, { emit: __emit }) {
12
12
  /**
13
- * 插入图片面板(UFilePicker):选文件后走共享 insertImageFromFile,锚定当前选区活动格。
13
+ * 插入图片面板:UFilePicker 选本地文件,或输入图片 URL 插入;均走共享
14
+ * insertImageFromFile / insertImageFromUrl,锚定当前选区活动格。
14
15
  * 不参与面板事务(单次插入 = 一个 undo 单元)。
15
16
  */
16
17
  const props = __props;
17
18
  const emit = __emit;
18
19
  const cls = bem("sheet");
20
+ const imageUrl = ref("");
19
21
  function handlePick(files) {
20
22
  const file = files[0];
21
23
  if (!file) return;
22
24
  emit("close");
23
25
  insertImageFromFile(props.context, file);
24
26
  }
27
+ function handleUrlInsert() {
28
+ if (!imageUrl.value.trim()) return;
29
+ emit("close");
30
+ insertImageFromUrl(props.context, imageUrl.value);
31
+ }
25
32
  return (_ctx, _cache) => {
26
- return openBlock(), createBlock(unref(UFilePicker), {
33
+ return openBlock(), createElementBlock(Fragment, null, [createVNode(unref(UFilePicker), {
27
34
  accept: unref(INSERT_IMAGE_ACCEPT),
28
35
  class: normalizeClass(unref(cls).e("insert-image-picker")),
29
36
  onPick: handlePick
30
37
  }, {
31
- default: withCtx(() => [createElementVNode("div", { class: normalizeClass(unref(cls).e("insert-image-hint")) }, [_cache[0] || (_cache[0] = createTextVNode(" 选择图片文件 ", -1)), createElementVNode("div", { class: normalizeClass(unref(cls).e("insert-image-sub")) }, "支持 png / jpeg / gif / svg / webp", 2)], 2)]),
38
+ default: withCtx(() => [createElementVNode("div", { class: normalizeClass(unref(cls).e("insert-image-hint")) }, [_cache[1] || (_cache[1] = createTextVNode(" 选择图片文件 ", -1)), createElementVNode("div", { class: normalizeClass(unref(cls).e("insert-image-sub")) }, "支持 png / jpeg / gif / svg / webp", 2)], 2)]),
39
+ _: 1
40
+ }, 8, ["accept", "class"]), createElementVNode("div", { class: normalizeClass(unref(cls).e("popup-row")) }, [createVNode(unref(UInput), {
41
+ modelValue: imageUrl.value,
42
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => imageUrl.value = $event),
43
+ placeholder: "输入图片 URL",
44
+ size: "small",
45
+ onKeydown: withKeys(handleUrlInsert, ["enter"])
46
+ }, null, 8, ["modelValue"]), createVNode(unref(UButton), {
47
+ size: "small",
48
+ type: "primary",
49
+ disabled: !imageUrl.value.trim(),
50
+ onClick: handleUrlInsert
51
+ }, {
52
+ default: withCtx(() => [..._cache[2] || (_cache[2] = [createTextVNode(" 插入 ", -1)])]),
32
53
  _: 1
33
- }, 8, ["accept", "class"]);
54
+ }, 8, ["disabled"])], 2)], 64);
34
55
  };
35
56
  }
36
57
  });
@@ -48,21 +48,29 @@ function resolveRenderSize(propsRows, propsCols, sheetRows, sheetCols) {
48
48
  cols: sheetCols > 0 ? sheetCols : propsCols ?? 26
49
49
  };
50
50
  }
51
- function insertCountRender(options) {
51
+ /** 菜单内嵌数量输入项(插入行列数 / 小数位数共用) */
52
+ function menuNumberRender(options) {
52
53
  return defineComponent({
53
- name: "SheetInsertCountMenuRender",
54
+ name: "SheetMenuNumberRender",
54
55
  setup() {
55
56
  return () => h(_sfc_main, {
56
57
  prefix: options.prefix,
57
58
  suffix: options.suffix,
58
59
  defaultValue: options.defaultValue,
59
- min: 1,
60
- max: INSERT_COUNT_MAX,
60
+ min: options.min,
61
+ max: options.max,
61
62
  onConfirm: options.onConfirm
62
63
  });
63
64
  }
64
65
  });
65
66
  }
67
+ function insertCountRender(options) {
68
+ return menuNumberRender({
69
+ ...options,
70
+ min: 1,
71
+ max: INSERT_COUNT_MAX
72
+ });
73
+ }
66
74
  function primaryRange(ctx) {
67
75
  return ctx.getSelection().ranges[0] ?? null;
68
76
  }
@@ -158,7 +166,14 @@ function buildColHeaderMenus(ctx) {
158
166
  }
159
167
  ];
160
168
  }
161
- /** body 格右键菜单:合并/取消合并 + 插入图片(行列插入/删除仅行号/列头菜单) */
169
+ /**
170
+ * 对当前选区应用 numFmt(经 applyStyle → 命令系统,天然可 undo/redo)。
171
+ * 仅影响显示,单元格恒存原始值(见 sheet-core `core/format.ts`)。
172
+ */
173
+ function applyNumFmtToSelection(ctx, numFmt) {
174
+ for (const range of ctx.getSelection().ranges) ctx.applyStyle(range, { numFmt });
175
+ }
176
+ /** body 格右键菜单:合并/取消合并 + 设置数据格式 + 插入图片(行列插入/删除仅行号/列头菜单) */
162
177
  function buildBodyMenus(ctx) {
163
178
  const mergeTool = defaultToolRegistry.get("merge");
164
179
  const unmergeTool = defaultToolRegistry.get("unmerge");
@@ -174,6 +189,40 @@ function buildBodyMenus(ctx) {
174
189
  disabled: unmergeTool?.disabled?.(ctx) ?? true,
175
190
  callback: () => unmergeTool?.onClick(ctx)
176
191
  },
192
+ { divider: true },
193
+ {
194
+ label: "设置数据格式",
195
+ disabled: !active,
196
+ children: [
197
+ {
198
+ label: "日期",
199
+ callback: () => applyNumFmtToSelection(ctx, { type: "date" })
200
+ },
201
+ {
202
+ label: "千分位金额",
203
+ callback: () => applyNumFmtToSelection(ctx, { type: "thousands" })
204
+ },
205
+ {
206
+ label: "大写金额",
207
+ callback: () => applyNumFmtToSelection(ctx, { type: "cnUpper" })
208
+ },
209
+ {
210
+ label: "小数位数",
211
+ keepOpen: true,
212
+ render: menuNumberRender({
213
+ prefix: "保留",
214
+ suffix: "位小数",
215
+ defaultValue: 2,
216
+ min: 0,
217
+ max: 10,
218
+ onConfirm: (n) => applyNumFmtToSelection(ctx, {
219
+ type: "fixed",
220
+ digits: n
221
+ })
222
+ })
223
+ }
224
+ ]
225
+ },
177
226
  {
178
227
  label: "插入图片",
179
228
  disabled: !active,
@@ -200,4 +249,4 @@ function ensureContextMenuSelection(ctx, info, renderSize) {
200
249
  }
201
250
  }
202
251
  //#endregion
203
- export { INSERT_COUNT_MAX, buildBodyMenus, buildColHeaderMenus, buildRowHeaderMenus, defaultInsertCount, ensureContextMenuSelection, isColInSelection, isRowInSelection, resolveRenderSize, wholeColRange, wholeRowRange };
252
+ export { INSERT_COUNT_MAX, applyNumFmtToSelection, buildBodyMenus, buildColHeaderMenus, buildRowHeaderMenus, defaultInsertCount, ensureContextMenuSelection, isColInSelection, isRowInSelection, resolveRenderSize, wholeColRange, wholeRowRange };
@@ -8,4 +8,5 @@ import "@veltra/desktop/components/loading/style";
8
8
  import "@veltra/desktop/components/message/style";
9
9
  import "@veltra/desktop/components/input/style";
10
10
  import "@veltra/desktop/components/file-picker/style";
11
+ import "@veltra/desktop/components/button/style";
11
12
  import "@veltra/desktop/components/icon/style";
@@ -9,4 +9,5 @@ import "@veltra/desktop/components/loading/style";
9
9
  import "@veltra/desktop/components/message/style";
10
10
  import "@veltra/desktop/components/input/style";
11
11
  import "@veltra/desktop/components/file-picker/style";
12
+ import "@veltra/desktop/components/button/style";
12
13
  import "@veltra/desktop/components/icon/style";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/sheet",
3
- "version": "2.5.4",
3
+ "version": "2.5.6",
4
4
  "homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/cabinet-fe/ultra-ui/issues"
@@ -36,11 +36,11 @@
36
36
  "peerDependencies": {
37
37
  "@cat-kit/core": ">=1.2.1",
38
38
  "@cat-kit/fe": ">=1.2.1",
39
- "@veltra/desktop": "^1.7.4",
39
+ "@veltra/desktop": "^1.7.10",
40
40
  "@veltra/icons": "^1.5.0",
41
- "@veltra/sheet-core": "^2.5.4",
42
- "@veltra/styles": "^1.7.4",
43
- "@veltra/utils": "^1.7.4",
41
+ "@veltra/sheet-core": "^2.5.6",
42
+ "@veltra/styles": "^1.7.10",
43
+ "@veltra/utils": "^1.7.10",
44
44
  "vue": ">=3.5.42"
45
45
  }
46
46
  }
@@ -39,6 +39,36 @@ vi.mock('@veltra/desktop', async () => {
39
39
  'pick'
40
40
  )
41
41
  }
42
+ }),
43
+ UInput: defineComponent({
44
+ name: 'UInputStub',
45
+ props: ['modelValue'],
46
+ emits: ['update:modelValue'],
47
+ setup(props, { emit }) {
48
+ return () =>
49
+ h('input', {
50
+ class: 'url-input-stub',
51
+ value: props.modelValue as string,
52
+ onInput: (e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value)
53
+ })
54
+ }
55
+ }),
56
+ UButton: defineComponent({
57
+ name: 'UButtonStub',
58
+ props: ['disabled'],
59
+ emits: ['click'],
60
+ setup(props, { emit, slots }) {
61
+ return () =>
62
+ h(
63
+ 'button',
64
+ {
65
+ class: 'url-btn-stub',
66
+ disabled: props.disabled as boolean,
67
+ onClick: () => emit('click')
68
+ },
69
+ slots.default?.()
70
+ )
71
+ }
42
72
  })
43
73
  }
44
74
  })
@@ -86,4 +116,24 @@ describe('USheetInsertImagePopup', () => {
86
116
  expect(sheet.getImages()[0]?.type).toBe('png')
87
117
  expect([...sheet.getImages()[0]!.data]).toEqual([9, 8, 7])
88
118
  })
119
+
120
+ it('输入 URL 点插入 → 关闭弹层并插入 src 来源图片', async () => {
121
+ const closed = vi.fn()
122
+ const { el, sheet } = mountPopup(closed)
123
+ const input = el.querySelector<HTMLInputElement>('.url-input-stub')!
124
+ const btn = el.querySelector<HTMLButtonElement>('.url-btn-stub')!
125
+ expect(btn.disabled).toBe(true)
126
+
127
+ input.value = 'https://example.com/pic.gif'
128
+ input.dispatchEvent(new Event('input'))
129
+ await Promise.resolve()
130
+ expect(btn.disabled).toBe(false)
131
+
132
+ btn.click()
133
+ expect(closed).toHaveBeenCalledTimes(1)
134
+ expect(sheet.getImages()).toHaveLength(1)
135
+ expect(sheet.getImages()[0]?.src).toBe('https://example.com/pic.gif')
136
+ expect(sheet.getImages()[0]?.type).toBe('gif')
137
+ expect(sheet.getImages()[0]?.anchor.from).toEqual({ row: 1, col: 1 })
138
+ })
89
139
  })
@@ -5,6 +5,7 @@ import { createSheetContext } from '../../../tools/context'
5
5
  import {
6
6
  INSERT_IMAGE_ACCEPT,
7
7
  insertImageFromFile,
8
+ insertImageFromUrl,
8
9
  pickAndInsertImage,
9
10
  resolveImageType
10
11
  } from '../insert-image'
@@ -84,6 +85,59 @@ describe('insertImageFromFile', () => {
84
85
  })
85
86
  })
86
87
 
88
+ describe('insertImageFromUrl', () => {
89
+ it('合法 URL → 锚定活动格并经 ctx.insertImage 写入 src 来源图片', () => {
90
+ const sheet = new Sheet()
91
+ const ctx = createSheetContext(sheet)
92
+ ctx.selectCell({ row: 2, col: 3 })
93
+ const id = insertImageFromUrl(ctx, 'https://example.com/assets/logo.webp')
94
+ expect(id).toBeTruthy()
95
+ const images = ctx.getImages()
96
+ expect(images).toHaveLength(1)
97
+ expect(images[0]?.src).toBe('https://example.com/assets/logo.webp')
98
+ expect(images[0]?.type).toBe('webp')
99
+ expect(images[0]?.data).toHaveLength(0)
100
+ expect(images[0]?.anchor.from).toEqual({ row: 2, col: 3 })
101
+ expect(mocks.message.error).not.toHaveBeenCalled()
102
+ })
103
+
104
+ it('URL 无已知扩展名 → type 按 png 提示,仍可插入', () => {
105
+ const sheet = new Sheet()
106
+ const ctx = createSheetContext(sheet)
107
+ ctx.selectCell({ row: 1, col: 1 })
108
+ const id = insertImageFromUrl(ctx, 'https://example.com/image?id=1')
109
+ expect(id).toBeTruthy()
110
+ expect(ctx.getImages()[0]?.type).toBe('png')
111
+ })
112
+
113
+ it('空串 → message.error,不写入', () => {
114
+ const sheet = new Sheet()
115
+ const ctx = createSheetContext(sheet)
116
+ ctx.selectCell({ row: 1, col: 1 })
117
+ expect(insertImageFromUrl(ctx, ' ')).toBeUndefined()
118
+ expect(ctx.getImages()).toHaveLength(0)
119
+ expect(mocks.message.error).toHaveBeenCalledWith('请输入图片 URL')
120
+ })
121
+
122
+ it('非法 URL → message.error,不写入', () => {
123
+ const sheet = new Sheet()
124
+ const ctx = createSheetContext(sheet)
125
+ ctx.selectCell({ row: 1, col: 1 })
126
+ expect(insertImageFromUrl(ctx, 'not a url')).toBeUndefined()
127
+ expect(ctx.getImages()).toHaveLength(0)
128
+ expect(mocks.message.error).toHaveBeenCalledWith('请输入有效的图片 URL')
129
+ })
130
+
131
+ it('无活动格 → message.error,不写入', () => {
132
+ const sheet = new Sheet()
133
+ const ctx = createSheetContext(sheet)
134
+ sheet.selection.clear()
135
+ expect(insertImageFromUrl(ctx, 'https://example.com/a.png')).toBeUndefined()
136
+ expect(ctx.getImages()).toHaveLength(0)
137
+ expect(mocks.message.error).toHaveBeenCalledWith('请先选择单元格')
138
+ })
139
+ })
140
+
87
141
  describe('pickAndInsertImage', () => {
88
142
  it('创建隐藏 file input(同一 accept)并 click', () => {
89
143
  const sheet = new Sheet()
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
4
4
 
5
5
  import { createSheetContext } from '../../../tools/context'
6
6
  import {
7
+ applyNumFmtToSelection,
7
8
  buildBodyMenus,
8
9
  buildColHeaderMenus,
9
10
  buildRowHeaderMenus,
@@ -147,13 +148,19 @@ describe('build*Menus', () => {
147
148
  ])
148
149
  })
149
150
 
150
- it('body 菜单三项:合并/取消合并/插入图片,不含行列插入删除', () => {
151
+ it('body 菜单:合并/取消合并 + 设置数据格式子菜单 + 插入图片,不含行列插入删除', () => {
151
152
  const sheet = new Sheet()
152
153
  const ctx = createSheetContext(sheet)
153
154
  sheet.selectCell({ row: 0, col: 0 })
154
155
  const menus = buildBodyMenus(ctx)
155
- expect(menus).toHaveLength(3)
156
- expect(menus.map((m) => m.label)).toEqual(['合并单元格', '取消合并单元格', '插入图片'])
156
+ expect(menus).toHaveLength(5)
157
+ expect(menus.map((m) => m.label ?? (m.divider ? '---' : ''))).toEqual([
158
+ '合并单元格',
159
+ '取消合并单元格',
160
+ '---',
161
+ '设置数据格式',
162
+ '插入图片'
163
+ ])
157
164
  const insertImage = menus.find((m) => m.label === '插入图片')!
158
165
  expect(insertImage.disabled).toBeFalsy()
159
166
  expect(insertImage.callback).toBeTruthy()
@@ -164,12 +171,92 @@ describe('build*Menus', () => {
164
171
  expect(labels).not.toContain('删除列')
165
172
  })
166
173
 
167
- it('body 插入图片:无活动格时禁用', () => {
174
+ it('body 设置数据格式子菜单:日期 / 千分位金额 / 大写金额 / 小数位数', () => {
175
+ const sheet = new Sheet()
176
+ const ctx = createSheetContext(sheet)
177
+ sheet.selectCell({ row: 0, col: 0 })
178
+ const sub = buildBodyMenus(ctx).find((m) => m.label === '设置数据格式')!
179
+ expect(sub.disabled).toBeFalsy()
180
+ const children = sub.children!
181
+ expect(children.map((c) => c.label)).toEqual(['日期', '千分位金额', '大写金额', '小数位数'])
182
+ // 小数位数项内嵌位数输入(keepOpen + render),其余三项直点直用
183
+ expect(children[3]?.keepOpen).toBe(true)
184
+ expect(children[3]?.render).toBeTruthy()
185
+ expect(children.slice(0, 3).every((c) => typeof c.callback === 'function')).toBe(true)
186
+ })
187
+
188
+ it('设置数据格式:四项各应用 numFmt 到当前选区全部单元格,原始值不变', () => {
189
+ const sheet = new Sheet()
190
+ const ctx = createSheetContext(sheet)
191
+ sheet.setCellValue({ row: 0, col: 0 }, 45000)
192
+ sheet.setCellValue({ row: 0, col: 1 }, 1234567.89)
193
+ const range = createRange({ row: 0, col: 0 }, { row: 0, col: 1 })
194
+
195
+ const cases = [
196
+ { label: '日期', numFmt: { type: 'date' } },
197
+ { label: '千分位金额', numFmt: { type: 'thousands' } },
198
+ { label: '大写金额', numFmt: { type: 'cnUpper' } }
199
+ ] as const
200
+ for (const c of cases) {
201
+ sheet.selectRange(range)
202
+ const sub = buildBodyMenus(ctx).find((m) => m.label === '设置数据格式')!
203
+ sub.children!.find((child) => child.label === c.label)!.callback!()
204
+ for (const addr of [
205
+ { row: 0, col: 0 },
206
+ { row: 0, col: 1 }
207
+ ]) {
208
+ expect(ctx.getCellStyle(addr)?.numFmt).toEqual(c.numFmt)
209
+ }
210
+ // 仅样式变化,原始值不变
211
+ expect(ctx.getCellData({ row: 0, col: 0 })?.v).toBe(45000)
212
+ expect(ctx.getCellData({ row: 0, col: 1 })?.v).toBe(1234567.89)
213
+ sheet.undo()
214
+ }
215
+
216
+ // 小数位数:经同一入口 applyNumFmtToSelection(render onConfirm 同路径)
217
+ sheet.selectRange(range)
218
+ applyNumFmtToSelection(ctx, { type: 'fixed', digits: 2 })
219
+ expect(ctx.getCellStyle({ row: 0, col: 0 })?.numFmt).toEqual({ type: 'fixed', digits: 2 })
220
+ expect(ctx.getCellStyle({ row: 0, col: 1 })?.numFmt).toEqual({ type: 'fixed', digits: 2 })
221
+ sheet.undo()
222
+ })
223
+
224
+ it('设置数据格式:undo/redo 恢复样式', () => {
225
+ const sheet = new Sheet()
226
+ const ctx = createSheetContext(sheet)
227
+ sheet.setCellValue({ row: 1, col: 1 }, 1.005)
228
+ sheet.selectRange(createRange({ row: 1, col: 1 }, { row: 1, col: 1 }))
229
+ const sub = buildBodyMenus(ctx).find((m) => m.label === '设置数据格式')!
230
+ sub.children!.find((c) => c.label === '千分位金额')!.callback!()
231
+ expect(ctx.getCellStyle({ row: 1, col: 1 })?.numFmt).toEqual({ type: 'thousands' })
232
+
233
+ expect(ctx.undo()).toBe(true)
234
+ expect(ctx.getCellStyle({ row: 1, col: 1 })?.numFmt).toBeUndefined()
235
+ expect(ctx.redo()).toBe(true)
236
+ expect(ctx.getCellStyle({ row: 1, col: 1 })?.numFmt).toEqual({ type: 'thousands' })
237
+ expect(ctx.getCellData({ row: 1, col: 1 })?.v).toBe(1.005)
238
+ })
239
+
240
+ it('设置数据格式:清空选区时为无操作(不产生 undo 单元)', () => {
241
+ const sheet = new Sheet()
242
+ const ctx = createSheetContext(sheet)
243
+ sheet.setCellValue({ row: 0, col: 0 }, 1)
244
+ sheet.selection.clear()
245
+ applyNumFmtToSelection(ctx, { type: 'date' })
246
+ expect(ctx.getCellStyle({ row: 0, col: 0 })).toBeUndefined()
247
+ // undo 直接回滚到 setCellValue 之前,证明未新增 undo 单元
248
+ expect(ctx.undo()).toBe(true)
249
+ expect(ctx.getCellData({ row: 0, col: 0 })).toBeUndefined()
250
+ expect(ctx.canUndo).toBe(false)
251
+ })
252
+
253
+ it('body 插入图片 / 设置数据格式:无活动格时禁用', () => {
168
254
  const sheet = new Sheet()
169
255
  const ctx = createSheetContext(sheet)
170
256
  sheet.selection.clear()
171
257
  const menus = buildBodyMenus(ctx)
172
258
  expect(menus.find((m) => m.label === '插入图片')?.disabled).toBe(true)
259
+ expect(menus.find((m) => m.label === '设置数据格式')?.disabled).toBe(true)
173
260
  })
174
261
 
175
262
  it('行号/列头菜单仍含插入删除行列', () => {
@@ -1,5 +1,5 @@
1
1
  import { message } from '@veltra/desktop'
2
- import type { SheetImageType } from '@veltra/sheet-core/core/image'
2
+ import type { SheetImageAnchor, SheetImageType } from '@veltra/sheet-core/core/image'
3
3
 
4
4
  import type { SheetContext } from '../../tools/context'
5
5
 
@@ -32,6 +32,16 @@ export function resolveImageType(file: File): SheetImageType | undefined {
32
32
  return EXT_TO_TYPE[ext.toLowerCase()]
33
33
  }
34
34
 
35
+ /** 取当前活动格锚点;无选区时 message.error 提示并返回 undefined */
36
+ function activeCellAnchor(ctx: SheetContext): SheetImageAnchor | undefined {
37
+ const active = ctx.getSelection().activeCell
38
+ if (!active) {
39
+ message.error('请先选择单元格')
40
+ return undefined
41
+ }
42
+ return { from: { row: active.row, col: active.col } }
43
+ }
44
+
35
45
  /**
36
46
  * 从本地 File 插入浮动图片:读字节 → 映射类型 → 锚定活动格 → ctx.insertImage。
37
47
  * 不支持的格式 / 无活动格时 message.error 提示并返回 undefined。
@@ -45,17 +55,35 @@ export async function insertImageFromFile(
45
55
  message.error('不支持的图片格式,请选择 png / jpeg / gif / svg / webp')
46
56
  return undefined
47
57
  }
48
- const active = ctx.getSelection().activeCell
49
- if (!active) {
50
- message.error('请先选择单元格')
58
+ const anchor = activeCellAnchor(ctx)
59
+ if (!anchor) return undefined
60
+ const buffer = await file.arrayBuffer()
61
+ return ctx.insertImage({ data: new Uint8Array(buffer), type, anchor })
62
+ }
63
+
64
+ /**
65
+ * 从 URL 插入浮动图片:URL 直存 src(渲染层直接引用),data 为空字节、
66
+ * type 仅作提示(取 URL 路径扩展名,未知按 png)。锚定活动格,经 ctx.insertImage 写入。
67
+ * 空串 / 非法 URL / 无活动格时 message.error 提示并返回 undefined。
68
+ */
69
+ export function insertImageFromUrl(ctx: SheetContext, url: string): string | undefined {
70
+ const src = url.trim()
71
+ if (!src) {
72
+ message.error('请输入图片 URL')
51
73
  return undefined
52
74
  }
53
- const buffer = await file.arrayBuffer()
54
- return ctx.insertImage({
55
- data: new Uint8Array(buffer),
56
- type,
57
- anchor: { from: { row: active.row, col: active.col } }
58
- })
75
+ let pathname: string
76
+ try {
77
+ pathname = new URL(src).pathname
78
+ } catch {
79
+ message.error('请输入有效的图片 URL')
80
+ return undefined
81
+ }
82
+ const anchor = activeCellAnchor(ctx)
83
+ if (!anchor) return undefined
84
+ const ext = pathname.includes('.') ? pathname.slice(pathname.lastIndexOf('.') + 1) : ''
85
+ const type = EXT_TO_TYPE[ext.toLowerCase()] ?? 'png'
86
+ return ctx.insertImage({ data: new Uint8Array(), type, src, anchor })
59
87
  }
60
88
 
61
89
  /**
@@ -9,19 +9,32 @@
9
9
  <div :class="cls.e('insert-image-sub')">支持 png / jpeg / gif / svg / webp</div>
10
10
  </div>
11
11
  </u-file-picker>
12
+ <div :class="cls.e('popup-row')">
13
+ <u-input
14
+ v-model="imageUrl"
15
+ placeholder="输入图片 URL"
16
+ size="small"
17
+ @keydown.enter="handleUrlInsert"
18
+ />
19
+ <u-button size="small" type="primary" :disabled="!imageUrl.trim()" @click="handleUrlInsert">
20
+ 插入
21
+ </u-button>
22
+ </div>
12
23
  </template>
13
24
 
14
25
  <script lang="ts" setup>
15
- import { UFilePicker } from '@veltra/desktop'
26
+ import { UButton, UFilePicker, UInput } from '@veltra/desktop'
16
27
  import { bem } from '@veltra/utils'
28
+ import { ref } from 'vue'
17
29
 
18
30
  import type { SheetContext } from '../../../tools/context'
19
- import { INSERT_IMAGE_ACCEPT, insertImageFromFile } from '../insert-image'
31
+ import { INSERT_IMAGE_ACCEPT, insertImageFromFile, insertImageFromUrl } from '../insert-image'
20
32
 
21
33
  defineOptions({ name: 'USheetInsertImagePopup' })
22
34
 
23
35
  /**
24
- * 插入图片面板(UFilePicker):选文件后走共享 insertImageFromFile,锚定当前选区活动格。
36
+ * 插入图片面板:UFilePicker 选本地文件,或输入图片 URL 插入;均走共享
37
+ * insertImageFromFile / insertImageFromUrl,锚定当前选区活动格。
25
38
  * 不参与面板事务(单次插入 = 一个 undo 单元)。
26
39
  */
27
40
  const props = defineProps<{ context: SheetContext }>()
@@ -30,10 +43,18 @@ const emit = defineEmits<{ close: [] }>()
30
43
 
31
44
  const cls = bem('sheet')
32
45
 
46
+ const imageUrl = ref('')
47
+
33
48
  function handlePick(files: File[]): void {
34
49
  const file = files[0]
35
50
  if (!file) return
36
51
  emit('close')
37
52
  void insertImageFromFile(props.context, file)
38
53
  }
54
+
55
+ function handleUrlInsert(): void {
56
+ if (!imageUrl.value.trim()) return
57
+ emit('close')
58
+ insertImageFromUrl(props.context, imageUrl.value)
59
+ }
39
60
  </script>
@@ -1,5 +1,6 @@
1
1
  import type { ContextmenuItem } from '@veltra/desktop'
2
2
  import { createRange, type CellAddress, type CellRange } from '@veltra/sheet-core/core/address'
3
+ import type { NumFmt } from '@veltra/sheet-core/core/style/types'
3
4
  import { defineComponent, h } from 'vue'
4
5
 
5
6
  import type { SheetContext } from '../../tools/context'
@@ -11,6 +12,10 @@ import { pickAndInsertImage } from './insert-image'
11
12
  export const INSERT_COUNT_MIN = 1
12
13
  export const INSERT_COUNT_MAX = 1000
13
14
 
15
+ /** 小数位数钳制范围 */
16
+ export const DECIMAL_PLACES_MIN = 0
17
+ export const DECIMAL_PLACES_MAX = 10
18
+
14
19
  /** 选区覆盖行/列数 → 插入默认 N(min 1 / max 1000) */
15
20
  export function defaultInsertCount(range: CellRange | null, axis: 'row' | 'col'): number {
16
21
  if (!range) return INSERT_COUNT_MIN
@@ -55,28 +60,40 @@ export function resolveRenderSize(
55
60
  }
56
61
  }
57
62
 
58
- function insertCountRender(options: {
63
+ /** 菜单内嵌数量输入项(插入行列数 / 小数位数共用) */
64
+ function menuNumberRender(options: {
59
65
  prefix: string
60
66
  suffix: string
61
67
  defaultValue: number
68
+ min: number
69
+ max: number
62
70
  onConfirm: (n: number) => void
63
71
  }): ContextmenuItem['render'] {
64
72
  return defineComponent({
65
- name: 'SheetInsertCountMenuRender',
73
+ name: 'SheetMenuNumberRender',
66
74
  setup() {
67
75
  return () =>
68
76
  h(InsertCountMenuItem, {
69
77
  prefix: options.prefix,
70
78
  suffix: options.suffix,
71
79
  defaultValue: options.defaultValue,
72
- min: INSERT_COUNT_MIN,
73
- max: INSERT_COUNT_MAX,
80
+ min: options.min,
81
+ max: options.max,
74
82
  onConfirm: options.onConfirm
75
83
  })
76
84
  }
77
85
  })
78
86
  }
79
87
 
88
+ function insertCountRender(options: {
89
+ prefix: string
90
+ suffix: string
91
+ defaultValue: number
92
+ onConfirm: (n: number) => void
93
+ }): ContextmenuItem['render'] {
94
+ return menuNumberRender({ ...options, min: INSERT_COUNT_MIN, max: INSERT_COUNT_MAX })
95
+ }
96
+
80
97
  function primaryRange(ctx: SheetContext): CellRange | null {
81
98
  return ctx.getSelection().ranges[0] ?? null
82
99
  }
@@ -171,7 +188,15 @@ export function buildColHeaderMenus(ctx: SheetContext): ContextmenuItem[] {
171
188
  ]
172
189
  }
173
190
 
174
- /** body 格右键菜单:合并/取消合并 + 插入图片(行列插入/删除仅行号/列头菜单) */
191
+ /**
192
+ * 对当前选区应用 numFmt(经 applyStyle → 命令系统,天然可 undo/redo)。
193
+ * 仅影响显示,单元格恒存原始值(见 sheet-core `core/format.ts`)。
194
+ */
195
+ export function applyNumFmtToSelection(ctx: SheetContext, numFmt: NumFmt): void {
196
+ for (const range of ctx.getSelection().ranges) ctx.applyStyle(range, { numFmt })
197
+ }
198
+
199
+ /** body 格右键菜单:合并/取消合并 + 设置数据格式 + 插入图片(行列插入/删除仅行号/列头菜单) */
175
200
  export function buildBodyMenus(ctx: SheetContext): ContextmenuItem[] {
176
201
  const mergeTool = defaultToolRegistry.get('merge')
177
202
  const unmergeTool = defaultToolRegistry.get('unmerge')
@@ -188,6 +213,28 @@ export function buildBodyMenus(ctx: SheetContext): ContextmenuItem[] {
188
213
  disabled: unmergeTool?.disabled?.(ctx) ?? true,
189
214
  callback: () => unmergeTool?.onClick(ctx)
190
215
  },
216
+ { divider: true },
217
+ {
218
+ label: '设置数据格式',
219
+ disabled: !active,
220
+ children: [
221
+ { label: '日期', callback: () => applyNumFmtToSelection(ctx, { type: 'date' }) },
222
+ { label: '千分位金额', callback: () => applyNumFmtToSelection(ctx, { type: 'thousands' }) },
223
+ { label: '大写金额', callback: () => applyNumFmtToSelection(ctx, { type: 'cnUpper' }) },
224
+ {
225
+ label: '小数位数',
226
+ keepOpen: true,
227
+ render: menuNumberRender({
228
+ prefix: '保留',
229
+ suffix: '位小数',
230
+ defaultValue: 2,
231
+ min: DECIMAL_PLACES_MIN,
232
+ max: DECIMAL_PLACES_MAX,
233
+ onConfirm: (n) => applyNumFmtToSelection(ctx, { type: 'fixed', digits: n })
234
+ })
235
+ }
236
+ ]
237
+ },
191
238
  { label: '插入图片', disabled: !active, callback: () => pickAndInsertImage(ctx) }
192
239
  ]
193
240
  }
@@ -7,7 +7,8 @@ import '@veltra/desktop/components/scroll/style'
7
7
  import '@veltra/desktop/components/tip/style'
8
8
  import '@veltra/desktop/components/loading/style' // v-loading(sheet.vue grid 容器)
9
9
  import '@veltra/desktop/components/message/style' // message / messageConfirm(弹层、tabs、公式栏)
10
- import '@veltra/desktop/components/input/style' // UInput(find-popup)
10
+ import '@veltra/desktop/components/input/style' // UInput(find-popup / insert-image-popup)
11
11
  import '@veltra/desktop/components/file-picker/style' // UFilePicker(insert-image-popup)
12
+ import '@veltra/desktop/components/button/style' // UButton(insert-image-popup URL 插入)
12
13
  import '@veltra/desktop/components/icon/style' // UIcon(sheet-toolbar / sheet-tabs)
13
14
  import './style.scss'