qrpa 1.0.24__tar.gz → 1.0.26__tar.gz

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.

Potentially problematic release.


This version of qrpa might be problematic. Click here for more details.

Files changed (32) hide show
  1. {qrpa-1.0.24 → qrpa-1.0.26}/PKG-INFO +1 -1
  2. {qrpa-1.0.24 → qrpa-1.0.26}/pyproject.toml +1 -1
  3. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/__init__.py +2 -1
  4. qrpa-1.0.26/qrpa/fun_base.py +337 -0
  5. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/fun_excel.py +159 -10
  6. qrpa-1.0.26/qrpa/shein_excel.py +2005 -0
  7. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/shein_lib.py +281 -0
  8. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa.egg-info/PKG-INFO +1 -1
  9. qrpa-1.0.24/qrpa/fun_base.py +0 -145
  10. qrpa-1.0.24/qrpa/shein_excel.py +0 -776
  11. {qrpa-1.0.24 → qrpa-1.0.26}/README.md +0 -0
  12. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/RateLimitedSender.py +0 -0
  13. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/db_migrator.py +0 -0
  14. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/fun_file.py +0 -0
  15. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/fun_web.py +0 -0
  16. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/fun_win.py +0 -0
  17. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/shein_daily_report_model.py +0 -0
  18. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/shein_sqlite.py +0 -0
  19. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/shein_ziniao.py +0 -0
  20. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/temu_chrome.py +0 -0
  21. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/temu_excel.py +0 -0
  22. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/temu_lib.py +0 -0
  23. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/time_utils.py +0 -0
  24. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/time_utils_example.py +0 -0
  25. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa/wxwork.py +0 -0
  26. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa.egg-info/SOURCES.txt +0 -0
  27. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa.egg-info/dependency_links.txt +0 -0
  28. {qrpa-1.0.24 → qrpa-1.0.26}/qrpa.egg-info/top_level.txt +0 -0
  29. {qrpa-1.0.24 → qrpa-1.0.26}/setup.cfg +0 -0
  30. {qrpa-1.0.24 → qrpa-1.0.26}/setup.py +0 -0
  31. {qrpa-1.0.24 → qrpa-1.0.26}/tests/test_db_migrator.py +0 -0
  32. {qrpa-1.0.24 → qrpa-1.0.26}/tests/test_wxwork.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qrpa
3
- Version: 1.0.24
3
+ Version: 1.0.26
4
4
  Summary: qsir's rpa library
5
5
  Author: QSir
6
6
  Author-email: QSir <1171725650@qq.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qrpa"
7
- version = "1.0.24"
7
+ version = "1.0.26"
8
8
  description = "qsir's rpa library"
9
9
  authors = [{ name = "QSir", email = "1171725650@qq.com" }]
10
10
  readme = "README.md"
@@ -3,7 +3,8 @@ from .db_migrator import DatabaseMigrator, DatabaseConfig, RemoteConfig, create_
3
3
 
4
4
  from .shein_ziniao import ZiniaoRunner
5
5
 
6
- from .fun_base import log, send_exception, md5_string, hostname, get_safe_value, sanitize_filename, get_file_size, calculate_star_symbols
6
+ # from .fun_base import log, send_exception, md5_string, hostname, get_safe_value, sanitize_filename, get_file_size, calculate_star_symbols
7
+ from .fun_base import *
7
8
 
8
9
  from .time_utils import TimeUtils
9
10
 
@@ -0,0 +1,337 @@
1
+ import inspect
2
+ import os
3
+ import traceback
4
+ import socket
5
+ import hashlib
6
+ import shutil
7
+
8
+ from datetime import datetime
9
+
10
+ from .wxwork import WxWorkBot
11
+
12
+ from .RateLimitedSender import RateLimitedSender
13
+
14
+ from typing import TypedDict
15
+
16
+ # 定义一个 TypedDict 来提供配置结构的类型提示
17
+
18
+ class ZiNiao(TypedDict):
19
+ company: str
20
+ username: str
21
+ password: str
22
+
23
+ class Config(TypedDict):
24
+ wxwork_bot_exception: str
25
+ ziniao: ZiNiao
26
+ auto_dir: str
27
+
28
+ def log(*args, **kwargs):
29
+ """封装 print 函数,使其行为与原 print 一致,并写入日志文件"""
30
+ stack = inspect.stack()
31
+ fi = stack[1] if len(stack) > 1 else None
32
+ log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}][{os.path.basename(fi.filename) if fi else 'unknown'}:{fi.lineno if fi else 0}:{fi.function if fi else 'unknown'}] " + " ".join(map(str, args))
33
+
34
+ print(log_message, **kwargs)
35
+
36
+ def hostname():
37
+ return socket.gethostname()
38
+
39
+ # ================= WxWorkBot 限频异常发送 =================
40
+ def send_exception(msg=None):
41
+ """
42
+ 发送异常到 WxWorkBot,限制发送频率,支持异步批量
43
+ """
44
+ # 首次调用时初始化限频发送器
45
+ if not hasattr(send_exception, "_wx_sender"):
46
+ def wxwork_bot_send(message):
47
+ bot_id = os.getenv('wxwork_bot_exception', 'ee5a048a-1b9e-41e4-9382-aa0ee447898e')
48
+ WxWorkBot(bot_id).send_text(message)
49
+
50
+ send_exception._wx_sender = RateLimitedSender(
51
+ sender_func=wxwork_bot_send,
52
+ interval=30, # 10 秒发一次
53
+ )
54
+
55
+ # 构造异常消息
56
+ error_msg = f'【{hostname()}】{datetime.now():%Y-%m-%d %H:%M:%S}\n{msg}\n'
57
+ error_msg += f'{traceback.format_exc()}'
58
+ print(error_msg)
59
+
60
+ # 异步发送
61
+ send_exception._wx_sender.send(error_msg)
62
+ return error_msg
63
+
64
+ def get_safe_value(data, key, default=0):
65
+ value = data.get(key)
66
+ return default if value is None else value
67
+
68
+ def md5_string(s):
69
+ # 需要先将字符串编码为 bytes
70
+ return hashlib.md5(s.encode('utf-8')).hexdigest()
71
+
72
+ # 将windows文件名不支持的字符替换成下划线
73
+ def sanitize_filename(filename):
74
+ # Windows 文件名非法字符
75
+ illegal_chars = r'\/:*?"<>|'
76
+ for char in illegal_chars:
77
+ filename = filename.replace(char, '_')
78
+
79
+ # 去除首尾空格和点
80
+ filename = filename.strip(' .')
81
+
82
+ # 替换连续多个下划线为单个
83
+ filename = '_'.join(filter(None, filename.split('_')))
84
+
85
+ return filename
86
+
87
+ def add_https(url):
88
+ if url and url.startswith('//'):
89
+ return 'https:' + url
90
+ return url
91
+
92
+ def create_file_path(file_path):
93
+ dir_name = os.path.dirname(file_path)
94
+ if dir_name and not os.path.exists(dir_name):
95
+ os.makedirs(dir_name, exist_ok=True) # 递归创建目录
96
+ return file_path
97
+
98
+ def copy_file(source, destination):
99
+ try:
100
+ shutil.copy2(source, destination)
101
+ print(f"文件已复制到 {destination}")
102
+ except FileNotFoundError:
103
+ print(f"错误:源文件 '{source}' 不存在")
104
+ except PermissionError:
105
+ print(f"错误:没有权限复制到 '{destination}'")
106
+ except Exception as e:
107
+ print(f"错误:发生未知错误 - {e}")
108
+
109
+ def get_file_size(file_path, human_readable=False):
110
+ """
111
+ 获取文件大小
112
+
113
+ :param file_path: 文件路径
114
+ :param human_readable: 是否返回可读格式(KB, MB, GB)
115
+ :return: 文件大小(字节数或可读格式)
116
+ """
117
+ if not os.path.isfile(file_path):
118
+ raise FileNotFoundError(f"文件不存在: {file_path}")
119
+
120
+ size_bytes = os.path.getsize(file_path)
121
+
122
+ if not human_readable:
123
+ return size_bytes
124
+
125
+ # 转换为可读单位
126
+ units = ["B", "KB", "MB", "GB", "TB"]
127
+ size = float(size_bytes)
128
+ for unit in units:
129
+ if size < 1024:
130
+ return f"{size:.2f} {unit}"
131
+ size /= 1024
132
+
133
+ def calculate_star_symbols(rating):
134
+ """
135
+ 计算星级对应的符号组合(独立评分逻辑函数)
136
+ 参数:
137
+ rating (int): 标准化评分(0-5)
138
+ 返回:
139
+ str: 星级符号字符串(如★★★⭐☆)
140
+ """
141
+ full_stars = int(rating)
142
+ empty_stars = 5 - full_stars
143
+ star_string = '★' * full_stars
144
+ star_string += '☆' * empty_stars
145
+ return star_string
146
+
147
+ def remove_columns(matrix, indices):
148
+ """
149
+ 过滤二维列表,移除指定索引的列
150
+
151
+ 参数:
152
+ matrix: 二维列表
153
+ indices: 需要移除的列索引列表
154
+
155
+ 返回:
156
+ 过滤后的二维列表
157
+ """
158
+ # 创建要保留的索引集合(排除需要移除的索引)
159
+ indices_to_keep = set(range(len(matrix[0]))) - set(indices)
160
+
161
+ # 遍历每行,只保留不在indices中的列
162
+ return [[row[i] for i in indices_to_keep] for row in matrix]
163
+
164
+
165
+ def filter_columns(matrix, indices):
166
+ """
167
+ 过滤二维列表,只保留指定索引的列
168
+
169
+ 参数:
170
+ matrix: 二维列表
171
+ indices: 需要保留的列索引列表
172
+
173
+ 返回:
174
+ 过滤后的二维列表
175
+ """
176
+ # 转置矩阵,获取每一列
177
+ columns = list(zip(*matrix))
178
+
179
+ # 只保留指定索引的列
180
+ filtered_columns = [columns[i] for i in indices]
181
+
182
+ # 将过滤后的列转回二维列表
183
+ return [list(row) for row in zip(*filtered_columns)]
184
+
185
+
186
+ # # 示例使用
187
+ # matrix = [
188
+ # [1, 2, 3, 4],
189
+ # [5, 6, 7, 8],
190
+ # [9, 10, 11, 12]
191
+ # ]
192
+ #
193
+ # # 只保留索引为 0 和 2 的列
194
+ # filtered = filter_columns(matrix, [0, 2])
195
+ # print(filtered) # 输出: [[1, 3], [5, 7], [9, 11]]
196
+
197
+ def add_column_to_2d_list(data, new_col, index=None):
198
+ """
199
+ 给二维列表增加一列数据
200
+
201
+ :param data: 原始二维列表,例如 [[1, 2], [3, 4]]
202
+ :param new_col: 要添加的新列数据,例如 [10, 20]
203
+ :param index: 插入位置,默认为最后一列之后;支持负数索引
204
+ :return: 增加新列后的二维列表
205
+ """
206
+ if not data:
207
+ raise ValueError("原始数据为空")
208
+ if len(data) != len(new_col):
209
+ raise ValueError("新列长度必须与原始数据的行数相等")
210
+
211
+ new_data = []
212
+ for i, row in enumerate(data):
213
+ row = list(row) # 防止修改原始数据
214
+ insert_at = index if index is not None else len(row)
215
+ row.insert(insert_at, new_col[i])
216
+ new_data.append(row)
217
+ return new_data
218
+
219
+
220
+ def add_prefixed_column(data, header, value):
221
+ """
222
+ 给二维列表增加第一列,第一行为 header,后面为 value。
223
+
224
+ :param data: 原始二维列表
225
+ :param header: 新列的标题
226
+ :param value: 新列内容(相同值)
227
+ :return: 增加新列后的二维列表
228
+ """
229
+ if not data:
230
+ raise ValueError("原始数据不能为空")
231
+
232
+ new_col = [header] + [value] * (len(data) - 1)
233
+ return [[new_col[i]] + row for i, row in enumerate(data)]
234
+
235
+
236
+ def add_suffixed_column(data, header, value):
237
+ """
238
+ 给二维列表增加第一列,第一行为 header,后面为 value。
239
+
240
+ :param data: 原始二维列表
241
+ :param header: 新列的标题
242
+ :param value: 新列内容(相同值)
243
+ :return: 增加新列后的二维列表
244
+ """
245
+ if not data:
246
+ raise ValueError("原始数据不能为空")
247
+
248
+ new_col = [header] + [value] * (len(data) - 1)
249
+ return [row + [new_col[i]] for i, row in enumerate(data)]
250
+
251
+
252
+ def merge_2d_lists_keep_first_header(data1, data2):
253
+ """
254
+ 合并两个二维列表,只保留第一个列表的标题(即第一行)。
255
+
256
+ :param data1: 第一个二维列表(包含标题)
257
+ :param data2: 第二个二维列表(包含标题)
258
+ :return: 合并后的二维列表
259
+ """
260
+ if not data1 or not isinstance(data1, list):
261
+ raise ValueError("data1 不能为空并且必须是二维列表")
262
+ if not data2 or not isinstance(data2, list):
263
+ raise ValueError("data2 不能为空并且必须是二维列表")
264
+
265
+ header = data1[0]
266
+ rows1 = data1[1:]
267
+ rows2 = data2[1:]
268
+
269
+ return [header] + rows1 + rows2
270
+
271
+
272
+ def insert_total_row(data, row_index=1, label="合计"):
273
+ """
274
+ 在指定行插入一行,第一列为 label,其余为空字符串。
275
+
276
+ :param data: 原始二维列表
277
+ :param row_index: 插入位置,默认插在第二行(索引1)
278
+ :param label: 第一列的标签内容,默认为 "合计"
279
+ :return: 新的二维列表
280
+ """
281
+ if not data or not isinstance(data, list):
282
+ raise ValueError("data 不能为空并且必须是二维列表")
283
+
284
+ num_cols = len(data[0])
285
+ new_row = [label] + [""] * (num_cols - 1)
286
+
287
+ return data[:row_index] + [new_row] + data[row_index:]
288
+
289
+
290
+ def insert_empty_column_after(data, col_index, new_header="单价成本"):
291
+ """
292
+ 在二维列表中指定列的后面插入一个新列,标题为 new_header,其余内容为空字符串。
293
+
294
+ :param data: 原始二维列表
295
+ :param col_index: 要插入的位置(在该列后面插入)
296
+ :param new_header: 新列的标题
297
+ :return: 新的二维列表
298
+ """
299
+ if not data or not isinstance(data, list):
300
+ raise ValueError("data 不能为空且必须是二维列表")
301
+
302
+ new_data = []
303
+ for i, row in enumerate(data):
304
+ row = list(row) # 复制避免修改原数据
305
+ insert_value = new_header if i == 0 else ""
306
+ row.insert(col_index + 1, insert_value)
307
+ new_data.append(row)
308
+
309
+ return new_data
310
+
311
+
312
+ def insert_empty_column_after_column_name(data, target_col_name, new_header="单价成本"):
313
+ """
314
+ 在指定列名对应的列后面插入一个新列,标题为 new_header,其余行为空字符串。
315
+
316
+ :param data: 原始二维列表
317
+ :param target_col_name: 要在哪一列之后插入(通过列标题匹配)
318
+ :param new_header: 插入的新列标题
319
+ :return: 新的二维列表
320
+ """
321
+ if not data or not isinstance(data, list):
322
+ raise ValueError("data 不能为空且必须是二维列表")
323
+
324
+ header = data[0]
325
+ if target_col_name not in header:
326
+ raise ValueError(f"找不到列名:{target_col_name}")
327
+
328
+ col_index = header.index(target_col_name)
329
+
330
+ new_data = []
331
+ for i, row in enumerate(data):
332
+ row = list(row) # 防止修改原始数据
333
+ insert_value = new_header if i == 0 else ""
334
+ row.insert(col_index + 1, insert_value)
335
+ new_data.append(row)
336
+
337
+ return new_data
@@ -542,7 +542,7 @@ def insert_fixed_scale_image(sheet, cell, image_path, scale=1.0):
542
542
 
543
543
  return None
544
544
 
545
- def InsertImageV2(sheet, columns=None, platform='shein', img_width=150, img_save_key=None, dir_name=None, cell_height_with_img=False):
545
+ def InsertImageV2(sheet, columns=None, platform='shein', img_width=150, img_save_key=None, dir_name=None, cell_height_with_img=False, start_row=2):
546
546
  if not columns:
547
547
  return
548
548
 
@@ -575,7 +575,7 @@ def InsertImageV2(sheet, columns=None, platform='shein', img_width=150, img_save
575
575
 
576
576
  # 预计算所有单元格的合并区域信息 (优化点1)
577
577
  area_map = {}
578
- for row in range(2, last_row + 1):
578
+ for row in range(start_row, last_row + 1):
579
579
  log(f'计算 {row}/{last_row}') # 如果数据量非常大,这里的日志会影响性能,可以考虑优化
580
580
  for col_letter in col_letter_map.values():
581
581
  cell_ref = f'{col_letter}{row}'
@@ -616,7 +616,7 @@ def InsertImageV2(sheet, columns=None, platform='shein', img_width=150, img_save
616
616
  }
617
617
 
618
618
  # 处理图片插入 (优化点2)
619
- for row in range(2, last_row + 1):
619
+ for row in range(start_row, last_row + 1):
620
620
  for img_col_name, col_letter in col_letter_map.items():
621
621
  cell_ref = f'{col_letter}{row}'
622
622
  cell_range = sheet.range(cell_ref)
@@ -1163,9 +1163,6 @@ def check_data(data):
1163
1163
  log(len(row), row)
1164
1164
 
1165
1165
  def write_data(excel_path, sheet_name, data, format_to_text_colunm=None):
1166
- log('write_data入参:', excel_path, sheet_name, 'data', format_to_text_colunm)
1167
- close_excel_file(excel_path)
1168
-
1169
1166
  app, wb, sheet = open_excel(excel_path, sheet_name)
1170
1167
  # 清空工作表中的所有数据
1171
1168
  sheet.clear()
@@ -1863,6 +1860,117 @@ def format_to_text_v2(sheet, columns=None):
1863
1860
  log(f'设置[{col_name}] 文本格式')
1864
1861
  sheet.range(f'{col_name}:{col_name}').number_format = '@'
1865
1862
 
1863
+ def format_to_text_v2_safe(sheet, columns=None, data_rows=None):
1864
+ """
1865
+ 更安全的文本格式化函数,避免COM异常
1866
+
1867
+ Args:
1868
+ sheet: Excel工作表对象
1869
+ columns: 要格式化的列名列表
1870
+ data_rows: 数据行数,用于限制格式化范围
1871
+ """
1872
+ if columns is None or len(columns) == 0:
1873
+ return
1874
+
1875
+ # 确保columns是列表
1876
+ if not isinstance(columns, list):
1877
+ columns = [columns]
1878
+
1879
+ for col_name in columns:
1880
+ try:
1881
+ if isinstance(col_name, int):
1882
+ col_name = xw.utils.col_name(col_name)
1883
+
1884
+ log(f'安全设置[{col_name}] 文本格式')
1885
+
1886
+ # 如果指定了数据行数,只格式化有数据的范围
1887
+ if data_rows and data_rows > 0:
1888
+ # 格式化从第1行到数据行数的范围
1889
+ range_str = f'{col_name}1:{col_name}{data_rows}'
1890
+ sheet.range(range_str).number_format = '@'
1891
+ else:
1892
+ # 检查列是否有数据,如果没有则跳过
1893
+ try:
1894
+ # 先检查第一个单元格是否存在
1895
+ test_range = sheet.range(f'{col_name}1')
1896
+ if test_range.value is not None or sheet.used_range.last_cell.column >= column_name_to_index(col_name) + 1:
1897
+ sheet.range(f'{col_name}:{col_name}').number_format = '@'
1898
+ else:
1899
+ log(f'列 {col_name} 没有数据,跳过格式化')
1900
+ except:
1901
+ log(f'列 {col_name} 格式化失败,跳过')
1902
+
1903
+ except Exception as e:
1904
+ log(f'设置列 {col_name} 文本格式失败: {e},继续处理其他列')
1905
+
1906
+ def pre_format_columns_safe(sheet, columns, data_rows):
1907
+ """
1908
+ 预格式化函数:在写入数据前安全地设置列格式
1909
+
1910
+ Args:
1911
+ sheet: Excel工作表对象
1912
+ columns: 要格式化的列名列表
1913
+ data_rows: 预期数据行数
1914
+ """
1915
+ if not columns or not isinstance(columns, list):
1916
+ return
1917
+
1918
+ for col_name in columns:
1919
+ try:
1920
+ if isinstance(col_name, int):
1921
+ col_name = xw.utils.col_name(col_name)
1922
+
1923
+ log(f'预格式化列 [{col_name}] 为文本格式')
1924
+
1925
+ # 方法1:先创建最小范围,避免整列操作
1926
+ try:
1927
+ # 创建足够大的范围来覆盖预期数据
1928
+ range_str = f'{col_name}1:{col_name}{max(data_rows, 1000)}'
1929
+ sheet.range(range_str).number_format = '@'
1930
+ log(f'预格式化成功: {range_str}')
1931
+ except Exception as e1:
1932
+ log(f'预格式化方法1失败: {e1}')
1933
+
1934
+ # 方法2:逐行设置格式,更安全但稍慢
1935
+ try:
1936
+ for row in range(1, data_rows + 1):
1937
+ cell = sheet.range(f'{col_name}{row}')
1938
+ cell.number_format = '@'
1939
+ log(f'逐行预格式化成功: {col_name}')
1940
+ except Exception as e2:
1941
+ log(f'逐行预格式化也失败: {e2}')
1942
+
1943
+ except Exception as e:
1944
+ log(f'预格式化列 {col_name} 失败: {e},继续处理其他列')
1945
+
1946
+ def post_format_columns_safe(sheet, columns, data_rows):
1947
+ """
1948
+ 后格式化函数:在写入数据后确认列格式
1949
+
1950
+ Args:
1951
+ sheet: Excel工作表对象
1952
+ columns: 要格式化的列名列表
1953
+ data_rows: 实际数据行数
1954
+ """
1955
+ if not columns or not isinstance(columns, list):
1956
+ return
1957
+
1958
+ for col_name in columns:
1959
+ try:
1960
+ if isinstance(col_name, int):
1961
+ col_name = xw.utils.col_name(col_name)
1962
+
1963
+ log(f'后格式化列 [{col_name}] 为文本格式')
1964
+
1965
+ # 只对实际有数据的行进行格式化
1966
+ if data_rows > 0:
1967
+ range_str = f'{col_name}1:{col_name}{data_rows}'
1968
+ sheet.range(range_str).number_format = '@'
1969
+ log(f'后格式化成功: {range_str}')
1970
+
1971
+ except Exception as e:
1972
+ log(f'后格式化列 {col_name} 失败: {e},继续处理其他列')
1973
+
1866
1974
  def format_to_text(sheet, columns=None):
1867
1975
  if columns is None:
1868
1976
  return
@@ -2065,6 +2173,21 @@ def format_to_number(sheet, columns=None, decimal_places=2):
2065
2173
  # else:
2066
2174
  # sheet.range(f'{col_name}:{col_name}').number_format = f'0.{"0" * decimal_places}'
2067
2175
 
2176
+ def hidden_columns(sheet, columns=None):
2177
+ if columns is None:
2178
+ return
2179
+ used_range_col = sheet.range('A1').expand('right')
2180
+ for j, cell in enumerate(used_range_col):
2181
+ col = j + 1
2182
+ col_name = index_to_column_name(col)
2183
+ col_val = sheet.range(f'{col_name}1').value
2184
+ if col_val is None:
2185
+ continue
2186
+ for c in columns:
2187
+ if c in col_val:
2188
+ log(f'设置[{c}] 隐藏')
2189
+ sheet.range(f'{col_name}:{col_name}').column_width = 0
2190
+
2068
2191
  def column_to_right(sheet, columns=None):
2069
2192
  if columns is None:
2070
2193
  return
@@ -2080,7 +2203,7 @@ def column_to_right(sheet, columns=None):
2080
2203
  # 水平对齐: # -4108:居中 # -4131:左对齐 # -4152:右对齐
2081
2204
  # 垂直对齐: # -4108:居中 # -4160:顶部对齐 # -4107:底部对齐
2082
2205
  # 所有列水平居中和垂直居中
2083
- log(f'设置[{c}] 水平垂直居中')
2206
+ log(f'设置[{c}] 水平右对齐')
2084
2207
  sheet.range(f'{col_name}:{col_name}').api.HorizontalAlignment = -4152
2085
2208
  sheet.range(f'{col_name}:{col_name}').api.VerticalAlignment = -4108
2086
2209
 
@@ -2121,6 +2244,16 @@ def beautify_title(sheet):
2121
2244
  sheet.range(f'{col_name}:{col_name}').api.VerticalAlignment = -4108
2122
2245
  sheet.autofit()
2123
2246
 
2247
+ def set_body_style(sheet, row_start, row_end=None):
2248
+ if row_end is None:
2249
+ row_end = get_last_used_row(sheet)
2250
+
2251
+ range = sheet.range(f'{row_start}:{row_end}')
2252
+ # 设置字体名称
2253
+ range.font.name = 'Calibri'
2254
+ # 设置字体大小
2255
+ range.font.size = 11
2256
+
2124
2257
  def set_title_style(sheet, rows=2):
2125
2258
  col = get_max_column_letter(sheet)
2126
2259
  range = sheet.range(f'A1:{col}{rows}')
@@ -2447,12 +2580,25 @@ def batch_excel_operations(excel_path, operations):
2447
2580
  data, format_to_text_colunm = args[0], args[1:] if len(args) > 1 else None
2448
2581
  # 清空工作表
2449
2582
  sheet.clear()
2450
- # 格式化文本列
2451
- if format_to_text_colunm:
2452
- format_to_text_v2(sheet, format_to_text_colunm)
2583
+
2584
+ # 先设置文本格式,再写入数据(确保格式生效)
2585
+ if format_to_text_colunm and format_to_text_colunm[0]:
2586
+ try:
2587
+ # 使用安全的预格式化方式
2588
+ pre_format_columns_safe(sheet, format_to_text_colunm[0], len(data))
2589
+ except Exception as e:
2590
+ log(f"预格式化失败: {e},继续执行")
2591
+
2453
2592
  # 写入数据
2454
2593
  sheet.range('A1').value = data
2455
2594
  log(f"批量操作:写入数据到 {sheet_name}")
2595
+
2596
+ # 写入后再次确认格式(双重保险)
2597
+ if format_to_text_colunm and format_to_text_colunm[0]:
2598
+ try:
2599
+ post_format_columns_safe(sheet, format_to_text_colunm[0], len(data))
2600
+ except Exception as e:
2601
+ log(f"后格式化失败: {e}")
2456
2602
 
2457
2603
  elif operation_type == 'format':
2458
2604
  format_func, format_args = args[0], args[1:] if len(args) > 1 else ()
@@ -2700,3 +2846,6 @@ def get_excel_status(excel_path):
2700
2846
  'operation_count': excel_lock_manager.get_operation_count(excel_path),
2701
2847
  'has_lock' : excel_lock_manager.get_file_lock(excel_path).locked()
2702
2848
  }
2849
+
2850
+ def get_last_used_row(sheet):
2851
+ return sheet.used_range.last_cell.row