feat: 工资查询助手首版 v1.0.105

基于 skill-template 脚手架开发,包含:
- payroll_config(费率配置,可编辑)+ payslip_snapshots(解析历史,只读)2 张业务表
- 全国默认 + 北上广 4 套费率(社保/公积金/个税起征点/7 级税率表)
- 5 个业务 Action:calculate-payslip-simple / calculate-payslip-full / explain-term / view-config / edit-config
- 个税累计预扣法计算,支持多月历史累加
- 20 个薪资名词库(社保/公积金/个税/全勤/加班费等)
- 完整市场文档(README/TUTORIAL/DEMO/CHANGELOG)
- 296 测试全部通过
This commit is contained in:
skill-dev
2026-09-07 17:02:05 +08:00
commit 3db196e49d
205 changed files with 29927 additions and 0 deletions

87
tools/README.md Normal file
View File

@@ -0,0 +1,87 @@
# skill-template 工具
## UI 拾取(开发期)
`tools/ui_inspector` 已迁出本模板仓。请使用独立产品仓:
- Gitea:https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder
- 本机常见路径:`D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`
- 启动:`python run.py`
模板仓历史中仍保留删除前的归档提交(可按需从 Git 恢复旧副本);日常开发与运行以独立仓为准。
## 业务仓也可以直接 clone(来源 A)
若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接:
```powershell
git clone https://git.jc2009.com/<org>/<query-payslip-default>.git
```
完整说明见 [`../development/DEVELOPMENT.md`](../development/DEVELOPMENT.md) §4「来源 A」。公开公共仓(含本模板、account-manager、platform-kit)登记见 [`../development/SHARED_REPOS.md`](../development/SHARED_REPOS.md)。下面脚手架用于**本地已有 skill-template、要新建尚未灌仓目录**的场景(来源 B)。
## 推荐:脚手架创建新技能
**不要**用资源管理器整文件夹复制 `skill-template`(会带走隐藏 `.git`,导致 push 串库)。
Windows 首选:
```powershell
cd D:\OpenClaw\client-commons\skill-template
.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill
```
跨平台(Python 3.10+):
```bash
python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new-skill
```
脚本行为:
- 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致)
- **排除**:`.git`、`.pytest_cache`、`__pycache__`、`.env`、`.env.local`、`*.pyc`
- 删除目标中的 `.openclaw-skill-template` 标记
- 将文本文件中的模板占位 slug 替换为新 slug:
- `query-payslip-default` → 新技能 kebab-case slug(如 `scrape-demo-records`)
- `query_payslip_default` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等)
- 覆盖 `assets/actions.json`、`SKILL.md`、`scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符
- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action
- **不**移除 Schema 中的 `bind.tables` 支持,**不**把 `bind` 当模板残留清理
- **不**自动生成业务表名,**不**给 `health` / `version` / `config-path` 自动加 `toolbar`
- **不**生成任何具体平台业务 Action
- **不**自动 `git init`;打印后续 Git 与测试命令
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
## 脚手架复制后必须保留 / 修改 / 可删除的文件
| 文件 | 说明 |
|------|------|
| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 |
| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 |
| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 |
| `tests/test_actions_manifest.py` | 若删除 `actions.json`,应同步删除或改写此测试 |
| `tests/test_data_management_contract.py` | 通用契约测试;建议保留,按需扩展本技能表结构测试 |
`demo_items` 等示例表仅存在于模板测试中,**不会**作为 scaffold 后的默认业务表复制进新技能。
## 手工复制后的补救
若已整目录复制,必须先清理再初始化:
```powershell
cd <新技能目录>
Remove-Item -Recurse -Force .git
Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
git remote -v
```
确认 `origin` **不是** skill-template 的远端 URL。
## 模板标记
源仓库根目录有 `.openclaw-skill-template`(仅 template 保留)。业务技能不得保留该文件;`tests/test_scaffold_guard.py` 会守护。

194
tools/highlight_answers.py Normal file
View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Highlight correct answer options in Excel quiz file."""
import re
import sys
from pathlib import Path
import openpyxl
from openpyxl.cell.rich_text import CellRichText, InlineFont, TextBlock
# Option letter to index mapping
LETTER_TO_IDX = {"A": 0, "B": 1, "C": 2, "D": 3}
IDX_TO_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"}
# Regex to find option boundaries: A/B/C/D followed by separator
OPTION_MARKER_RE = re.compile(
r"(?=[A-D](?:[\..、::\))]|\s))"
)
NORMAL_FONT = InlineFont(b=False, color="000000")
RED_BOLD_FONT = InlineFont(b=True, color="FF0000")
def parse_answer(answer_raw) -> set[int]:
"""Parse answer column into set of option indices (0=A, 1=B, ...)."""
if answer_raw is None:
return set()
s = str(answer_raw).strip()
if not s:
return set()
indices = set()
# Pure digits like "13" -> 1 and 3 (not 13)
if re.fullmatch(r"[1-4]+", s):
for ch in s:
indices.add(int(ch) - 1)
return indices
# Split by common separators
parts = re.split(r"[,,;;\s|]+", s)
for part in parts:
part = part.strip().upper()
if not part:
continue
if part in LETTER_TO_IDX:
indices.add(LETTER_TO_IDX[part])
elif re.fullmatch(r"[1-4]", part):
indices.add(int(part) - 1)
return indices
def find_option_spans(text: str) -> list[tuple[int, int, str]]:
"""Find spans for each A/B/C/D option in the options text."""
if not text or not isinstance(text, str):
return []
markers = []
for m in OPTION_MARKER_RE.finditer(text):
letter = text[m.start()]
markers.append((m.start(), letter))
if not markers:
return []
spans = []
for i, (start, letter) in enumerate(markers):
end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
spans.append((start, end, letter))
return spans
def build_rich_text(options_text: str, answer_indices: set[int]) -> CellRichText | str:
"""Build rich text with correct options in red bold."""
spans = find_option_spans(options_text)
if not spans:
return options_text
parts = []
pos = 0
for start, end, letter in spans:
if start > pos:
parts.append(TextBlock(NORMAL_FONT, options_text[pos:start]))
idx = LETTER_TO_IDX.get(letter)
font = RED_BOLD_FONT if idx is not None and idx in answer_indices else NORMAL_FONT
parts.append(TextBlock(font, options_text[start:end]))
pos = end
if pos < len(options_text):
parts.append(TextBlock(NORMAL_FONT, options_text[pos:]))
return CellRichText(*parts)
def load_workbook_from_xls(xls_path: Path) -> openpyxl.Workbook:
"""Load .xls file, converting via pandas/xlrd if needed."""
try:
import pandas as pd
xls_data = pd.read_excel(xls_path, sheet_name=None, header=None, dtype=str)
wb = openpyxl.Workbook()
# Remove default sheet
wb.remove(wb.active)
for sheet_name, df in xls_data.items():
ws = wb.create_sheet(title=str(sheet_name))
for r_idx, row in enumerate(df.itertuples(index=False), start=1):
for c_idx, val in enumerate(row, start=1):
if val is not None and str(val) != "nan":
ws.cell(row=r_idx, column=c_idx, value=str(val))
return wb
except Exception as e:
raise RuntimeError(f"Failed to read .xls file: {e}") from e
def find_header_columns(ws) -> tuple[int, int, int] | None:
"""Find header row and column indices for 题目选项 and 题目答案."""
for row_idx in range(1, min(ws.max_row + 1, 20)):
options_col = None
answer_col = None
for col_idx in range(1, ws.max_column + 1):
val = ws.cell(row=row_idx, column=col_idx).value
if val is None:
continue
s = str(val).strip()
if s == "题目选项":
options_col = col_idx
elif s == "题目答案":
answer_col = col_idx
if options_col is not None and answer_col is not None:
return row_idx, options_col, answer_col
return None
def process_sheet(ws, stats: dict) -> bool:
"""Process a single worksheet. Returns True if processed."""
header_info = find_header_columns(ws)
if header_info is None:
return False
header_row, options_col, answer_col = header_info
stats["sheets"] += 1
sheet_rows = 0
sheet_matches = 0
for row_idx in range(header_row + 1, ws.max_row + 1):
options_cell = ws.cell(row=row_idx, column=options_col)
answer_cell = ws.cell(row=row_idx, column=answer_col)
options_text = options_cell.value
if options_text is None:
continue
options_text = str(options_text).strip()
if not options_text:
continue
answer_indices = parse_answer(answer_cell.value)
if not answer_indices:
continue
sheet_rows += 1
sheet_matches += len(answer_indices)
rich = build_rich_text(options_text, answer_indices)
options_cell.value = rich
stats["rows"] += sheet_rows
stats["matches"] += sheet_matches
return True
def main():
src = Path(r"D:\下载\安全知识竞赛题库(1).xls")
dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx")
if not src.exists():
print(f"ERROR: Source file not found: {src}")
sys.exit(1)
print(f"Reading: {src}")
wb = load_workbook_from_xls(src)
stats = {"sheets": 0, "rows": 0, "matches": 0}
for ws in wb.worksheets:
process_sheet(ws, stats)
wb.save(dst)
print(f"Saved: {dst}")
print(f"处理结果: {stats['sheets']} 个工作表, {stats['rows']} 行, {stats['matches']} 个匹配选项")
if __name__ == "__main__":
main()

140
tools/scaffold_skill.ps1 Normal file
View File

@@ -0,0 +1,140 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Scaffold a new skill directory from skill-template (excludes .git).
.EXAMPLE
.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Slug,
[Parameter(Mandatory = $true)]
[string]$Destination,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function Test-KebabSlug([string]$Value) {
return $Value -match '^[a-z0-9]+(-[a-z0-9]+)*$'
}
function Write-ScaffoldNextSteps([string]$TargetPath) {
Write-Host ""
Write-Host "=== 脚手架复制完成 ===" -ForegroundColor Green
Write-Host ("目标目录: " + $TargetPath)
Write-Host ""
Write-Host "请在本机继续执行(未自动 git init,避免误操作):" -ForegroundColor Yellow
Write-Host (' cd "' + $TargetPath + '"')
Write-Host " Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue"
Write-Host " git init"
Write-Host " git remote add origin <你的新技能仓库 URL>"
Write-Host " git remote -v"
Write-Host " # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件"
Write-Host " python tests/run_tests.py -v"
Write-Host ""
}
$TemplateRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$TargetPath = $Destination
if (-not (Test-KebabSlug $Slug)) {
throw "Slug must be kebab-case. Got: $Slug"
}
$leaf = Split-Path -Leaf ($TargetPath.TrimEnd('\', '/'))
if ($leaf -ne $Slug) {
throw "Destination leaf '$leaf' must match -Slug '$Slug'."
}
$parent = Split-Path -Parent $TargetPath
if (-not $parent -or -not (Test-Path -LiteralPath $parent)) {
throw "Parent directory does not exist: $parent"
}
$gitPath = Join-Path $TargetPath ".git"
if (Test-Path -LiteralPath $gitPath) {
throw "Target already has .git at $gitPath . Remove it first or use an empty destination."
}
$targetExists = Test-Path -LiteralPath $TargetPath
if ($targetExists) {
$entries = @(Get-ChildItem -LiteralPath $TargetPath -Force -ErrorAction SilentlyContinue)
if ($entries.Count -gt 0 -and -not $Force) {
throw "Target exists and is not empty: $TargetPath . Use -Force or pick an empty directory."
}
}
$excludeDirs = @(".git", ".pytest_cache", "__pycache__")
$excludeFiles = @(".env", ".env.local")
$templatePlaceholderSlugKebab = "query-payslip-default"
$templatePlaceholderSlugUnderscore = "query_payslip_default"
$textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample")
function Copy-TemplateTree {
param(
[string]$Source,
[string]$Dest
)
if (-not (Test-Path -LiteralPath $Dest)) {
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
}
Get-ChildItem -LiteralPath $Source -Force | ForEach-Object {
$name = $_.Name
if ($excludeDirs -contains $name) { return }
if ($excludeFiles -contains $name) { return }
if ($name -like "*.pyc") { return }
$srcPath = $_.FullName
$dstPath = Join-Path $Dest $name
if ($_.PSIsContainer) {
Copy-TemplateTree -Source $srcPath -Dest $dstPath
}
else {
Copy-Item -LiteralPath $srcPath -Destination $dstPath -Force
}
}
}
function Replace-TemplatePlaceholders {
param(
[string]$Root,
[string]$Slug
)
$underscoreSlug = $Slug.Replace("-", "_")
Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object {
$name = $_.Name
if ($excludeFiles -contains $name) { return }
$ext = $_.Extension.ToLowerInvariant()
if ($textFileSuffixes -notcontains $ext -and $name -ne ".env.example") { return }
try {
$text = [System.IO.File]::ReadAllText($_.FullName)
}
catch {
return
}
if (
$text -notlike "*$templatePlaceholderSlugKebab*" -and
$text -notlike "*$templatePlaceholderSlugUnderscore*"
) { return }
$updated = $text.Replace($templatePlaceholderSlugKebab, $Slug)
$updated = $updated.Replace($templatePlaceholderSlugUnderscore, $underscoreSlug)
[System.IO.File]::WriteAllText($_.FullName, $updated)
}
}
Write-Host "Copy template: $TemplateRoot -> $TargetPath" -ForegroundColor Cyan
Copy-TemplateTree -Source $TemplateRoot -Dest $TargetPath
$marker = Join-Path $TargetPath ".openclaw-skill-template"
if (Test-Path -LiteralPath $marker) {
Remove-Item -LiteralPath $marker -Force
}
Replace-TemplatePlaceholders -Root $TargetPath -Slug $Slug
Write-ScaffoldNextSteps -TargetPath (Resolve-Path -LiteralPath $TargetPath).Path

161
tools/scaffold_skill.py Normal file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""跨平台 skill-template 脚手架(与 scaffold_skill.ps1 行为一致)。"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from pathlib import Path
EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"}
EXCLUDE_FILE_NAMES = {".env", ".env.local"}
KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
TEMPLATE_PLACEHOLDER_SLUG_KEBAB = "query-payslip-default"
TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE = "query_payslip_default"
TEXT_FILE_SUFFIXES = {
".md",
".py",
".json",
".yaml",
".yml",
".txt",
".ini",
".ps1",
".sample",
".env.example",
}
def _ignore_copy(_dir: str, names: list[str]) -> set[str]:
ignored: set[str] = set()
for name in names:
if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES:
ignored.add(name)
elif name.endswith(".pyc"):
ignored.add(name)
return ignored
def _should_replace_placeholders(path: Path) -> bool:
if path.name in EXCLUDE_FILE_NAMES:
return False
if path.suffix.lower() in TEXT_FILE_SUFFIXES:
return True
if path.name == ".env.example":
return True
return False
def _slug_to_underscore(slug: str) -> str:
return slug.replace("-", "_")
def _replace_template_placeholders(target: Path, slug: str) -> None:
"""将模板占位 slug 替换为新技能 slug(不修改 action 业务命令结构)。"""
underscore_slug = _slug_to_underscore(slug)
for path in target.rglob("*"):
if not path.is_file():
continue
if path.name in EXCLUDE_FILE_NAMES:
continue
if path.suffix.lower() not in TEXT_FILE_SUFFIXES and path.name != ".env.example":
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if (
TEMPLATE_PLACEHOLDER_SLUG_KEBAB not in text
and TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE not in text
):
continue
updated = text.replace(TEMPLATE_PLACEHOLDER_SLUG_KEBAB, slug)
updated = updated.replace(TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE, underscore_slug)
path.write_text(updated, encoding="utf-8")
def _print_next_steps(target: Path) -> None:
print()
print("=== 脚手架复制完成 ===")
print(f"目标目录: {target}")
print()
print("请在本机继续执行(未自动 git init,避免误操作):")
print(f' cd "{target}"')
print(" Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue # 若曾手工复制漏删")
print(" git init")
print(" git remote add origin <你的新技能仓库 URL>")
print(" git remote -v # 确认 remote 不是 skill-template")
print(" # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件")
print(" python tests/run_tests.py -v")
print()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="从 skill-template 脚手架复制新技能目录")
parser.add_argument("--slug", required=True, help="kebab-case slug,须与目标目录名一致")
parser.add_argument("--destination", required=True, help="新技能完整目标路径")
parser.add_argument("--force", action="store_true", help="目标非空时允许覆盖(目标不得含 .git)")
args = parser.parse_args(argv)
slug = args.slug.strip()
if not KEBAB_SLUG.match(slug):
print(f"ERROR: Slug 必须是 kebab-case,当前: {slug}", file=sys.stderr)
return 1
target = Path(args.destination).resolve()
if target.name != slug:
print(
f"ERROR: 目标目录末段 '{target.name}' 与 slug '{slug}' 不一致。",
file=sys.stderr,
)
return 1
parent = target.parent
if not parent.is_dir():
print(f"ERROR: 父目录不存在: {parent}", file=sys.stderr)
return 1
if (target / ".git").exists():
print(
f"ERROR: 目标已存在 .git,请先删除: {target / '.git'}",
file=sys.stderr,
)
return 1
template_root = Path(__file__).resolve().parent.parent
if target.exists():
entries = list(target.iterdir())
if entries and not args.force:
print(
f"ERROR: 目标目录已存在且非空: {target}\n请加 --force 或换空目录。",
file=sys.stderr,
)
return 1
target.mkdir(parents=True, exist_ok=True)
print(f"复制模板: {template_root} -> {target}")
for item in template_root.iterdir():
name = item.name
if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES:
continue
if name.endswith(".pyc"):
continue
dest = target / name
if item.is_dir():
shutil.copytree(item, dest, ignore=_ignore_copy, dirs_exist_ok=True)
else:
shutil.copy2(item, dest)
marker = target / ".openclaw-skill-template"
if marker.is_file():
marker.unlink()
_replace_template_placeholders(target, slug)
_print_next_steps(target)
return 0
if __name__ == "__main__":
raise SystemExit(main())

57
tools/verify_highlight.py Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Verify highlighted answer output file."""
from pathlib import Path
import openpyxl
dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx")
src = Path(r"D:\下载\安全知识竞赛题库(1).xls")
print(f"Source exists: {src.exists()}")
print(f"Output exists: {dst.exists()}")
wb = openpyxl.load_workbook(dst, rich_text=True)
ws = wb.active
print(f"Sheet: {ws.title}, rows={ws.max_row}, cols={ws.max_column}")
# Find header columns
header_row = None
options_col = None
answer_col = None
for row_idx in range(1, 20):
for col_idx in range(1, ws.max_column + 1):
val = ws.cell(row=row_idx, column=col_idx).value
if val and str(val).strip() == "题目选项":
options_col = col_idx
if val and str(val).strip() == "题目答案":
answer_col = col_idx
if options_col and answer_col:
header_row = row_idx
break
print(f"Header row={header_row}, options_col={options_col}, answer_col={answer_col}")
# Sample verification for first 5 rows with answers 1,2,3
samples = []
for row_idx in range(header_row + 1, ws.max_row + 1):
answer = ws.cell(row=row_idx, column=answer_col).value
if answer in ("1", "2", "3", 1, 2, 3):
cell = ws.cell(row=row_idx, column=options_col)
val = cell.value
runs = []
if hasattr(val, "__iter__") and not isinstance(val, str):
for block in val:
font = block.font
bold = font.b if font else None
color = font.color if font else None
runs.append((block.text, bold, color))
samples.append((row_idx, answer, runs))
if len(samples) >= 5:
break
for row_idx, answer, runs in samples:
print(f"\nRow {row_idx}, answer={answer}:")
for text, bold, color in runs:
marker = "RED_BOLD" if bold and color == "FF0000" else "normal"
print(f" [{marker}] {repr(text[:50])}")