feat: 完善名师工作室内容与成员悬浮导航

更新工作室页面、成员资料与静态资源,保留成员导航的渐变及定位交互。排除视频和超过 10 MiB 的新增部署资源。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Code committed 2026-10-15 11:36:05 +08:00
1 parent 53d0eeac6b
commit 45973a5145
374 files changed
+9793 -1292

No files matched your search

+320
View File
@@ -0,0 +1,320 @@
"""Restore reviewed DOCX layout and media without exposing excluded source data.
Run with python3 -I and explicit --source / --project paths. Source files are
never modified. --check only validates the generated packages and references.
Old .doc files remain the existing clearly-labelled text conversions: this tool
never invokes an unapproved Office converter. No new third-party dependency is
needed beyond lxml (already installed alongside python-docx).
"""
import argparse
import copy
import hashlib
import io
import json
import posixpath
import re
import tempfile
import zipfile
from pathlib import Path
from lxml import etree as ET
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
PKG = 'http://schemas.openxmlformats.org/package/2006/relationships'
NS = {'w': W, 'r': R}
# Explicitly reviewed media allowlists: unknown new images stop the import.
MEDIA = {
'yy-e8aba6fc581d': {'image1.jpeg'},
'yy-d1ac14d56e79': {'image1.jpeg', 'image2.jpeg', 'image3.jpeg', 'image4.jpeg'},
'yy-1be8039474b0': {'image1.jpeg'},
'yy-46cd9da9d1c7': {'image1.jpeg'},
'yy-e44ee7185d30': {'image1.jpeg'},
'yy-c018fd1d6577': {f'image{i}.jpeg' for i in range(1, 6)},
'yy-4a9181c97fae': {'image1.png', 'image2.jpeg', 'image3.jpeg'},
'yy-4beac9bea263': {f'image{i}.jpeg' for i in range(1, 10)},
}
EXCLUDE = {
'yy-1be8039474b0': {'image1.jpeg': '专家手写签名,展示副本移除'},
'yy-46cd9da9d1c7': {'image1.jpeg': '专家手写签名,展示副本移除'},
'yy-4beac9bea263': {
f'image{i}.jpeg': '颁奖投屏可读学生姓名和班级,待提供脱敏图片'
for i in (4, 5, 6)
},
}
# These events lacked any standalone photographs. Other restored photographs
# stay in their original document context rather than duplicating existing galleries.
GALLERY = {
'yy-e44ee7185d30': {'image1.jpeg': '智慧康养人才培养改革方案教研现场'},
'yy-4beac9bea263': {
'image1.jpeg': '护士节主题活动致辞',
'image2.jpeg': '护士节主题活动舞台展示',
'image3.jpeg': '护理服饰演绎与集体展示',
'image7.jpeg': '护理学子宣誓仪式',
'image8.jpeg': '护理传光授帽仪式',
'image9.jpeg': '护士节主题活动集体合影',
},
}
PATTERNS = [
(re.compile(r'1982\s*年生[,,]?'), ''),
(re.compile(r'1999\.10[,,]?'), ''),
(re.compile(r'(?<!\d)1[3-9]\d{9}(?!\d)'), '〔个人手机信息已略〕'),
(re.compile(r'(?<!\d)\d{17}[\dXx](?!\d)'), '〔证件信息已略〕'),
]
def digest(blob):
return hashlib.sha256(blob).hexdigest()
def xml_bytes(root):
return ET.tostring(root, encoding='UTF-8', xml_declaration=True, standalone=True)
def remove(node):
if node.getparent() is not None:
node.getparent().remove(node)
def redact_runs(paragraph):
"""Edit only matched text spans, including matches split across Word runs."""
for regex, replacement in PATTERNS:
nodes = paragraph.findall('.//w:t', NS)
text = ''.join(node.text or '' for node in nodes)
matches = list(regex.finditer(text))
if not matches:
continue
offset = 0
for node in nodes:
value = node.text or ''
end = offset + len(value)
result = []
for i, char in enumerate(value):
position = offset + i
match = next((m for m in matches if m.start() <= position < m.end()), None)
if match is None:
result.append(char)
elif position == match.start():
result.append(replacement)
node.text = ''.join(result)
offset = end
def restore_package(source_blob, attachment_id):
with zipfile.ZipFile(io.BytesIO(source_blob)) as archive:
package = {name: archive.read(name) for name in archive.namelist() if not name.endswith('/')}
media = {Path(name).name for name in package if name.startswith('word/media/')}
if media != MEDIA.get(attachment_id, set()):
raise ValueError(f'Unexpected media requires review: {attachment_id}: {sorted(media)}')
if any(name.startswith('word/embeddings/') or name.endswith('vbaProject.bin') for name in package):
raise ValueError(f'Embedded executable/object requires review: {attachment_id}')
blocked_media = {'word/media/' + name for name in EXCLUDE.get(attachment_id, {})}
dropped = {name for name in package if name.startswith('customXml/')
or name.startswith('docProps/thumbnail')
or '/comments' in name or '/people' in name}
dropped |= blocked_media
rel_root = ET.fromstring(package['word/_rels/document.xml.rels']) if 'word/_rels/document.xml.rels' in package else None
blocked_ids = set()
if rel_root is not None:
for relationship in rel_root:
target = posixpath.normpath(posixpath.join('word', relationship.get('Target', '')))
if target in blocked_media:
blocked_ids.add(relationship.get('Id'))
before_root = ET.fromstring(package['word/document.xml'])
before_tables = len(before_root.findall('.//w:tbl', NS))
removed_drawings = 0
for name in list(package):
if name in dropped:
continue
if name.endswith('.rels'):
root = ET.fromstring(package[name])
base = '' if name == '_rels/.rels' else posixpath.dirname(posixpath.dirname(name))
for relationship in list(root):
target = posixpath.normpath(posixpath.join(base, relationship.get('Target', '')))
if target in dropped:
root.remove(relationship)
elif relationship.get('TargetMode') == 'External':
raise ValueError(f'Unexpected external relation requires review: {attachment_id}')
package[name] = xml_bytes(root)
elif name.endswith('.xml') and name.startswith('word/'):
root = ET.fromstring(package[name])
# Keep current visible content, not hidden revision histories or comments.
for node in list(root.xpath('//w:del|//w:moveFrom|//w:commentRangeStart|//w:commentRangeEnd|//w:commentReference', namespaces=NS)):
remove(node)
for node in list(root.xpath('//w:ins|//w:moveTo', namespaces=NS)):
parent = node.getparent()
if parent is not None:
position = parent.index(node)
for child in list(node):
parent.insert(position, child)
position += 1
remove(node)
for run in list(root.xpath('//w:r[w:rPr/w:vanish]', namespaces=NS)):
remove(run)
for drawing in list(root.xpath('//w:drawing|//w:pict', namespaces=NS)):
if any(value in blocked_ids for element in drawing.iter() for key, value in element.attrib.items() if key.startswith('{' + R + '}')):
remove(drawing)
removed_drawings += 1
for paragraph in root.findall('.//w:p', NS):
redact_runs(paragraph)
# Strip creator/path data in drawing alternative descriptions and edit IDs.
for element in root.iter():
for key in list(element.attrib):
local = ET.QName(key).localname
if local.startswith('rsid') or local in ('author', 'initials', 'descr'):
del element.attrib[key]
if ET.QName(element).localname in ('docPr', 'cNvPr'):
element.set('name', 'Reviewed illustration')
element.attrib.pop('title', None)
package[name] = xml_bytes(root)
core = ET.Element('{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}coreProperties', nsmap={'cp': 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties'})
package['docProps/core.xml'] = xml_bytes(core)
if 'docProps/app.xml' in package:
app = ET.fromstring(package['docProps/app.xml'])
for element in app.iter():
if ET.QName(element).localname in ('Company', 'Manager', 'Template', 'HyperlinkBase'):
element.text = ''
package['docProps/app.xml'] = xml_bytes(app)
types = ET.fromstring(package['[Content_Types].xml'])
for element in list(types):
if element.get('PartName', '').lstrip('/') in dropped:
types.remove(element)
package['[Content_Types].xml'] = xml_bytes(types)
for name in dropped:
package.pop(name, None)
final_root = ET.fromstring(package['word/document.xml'])
assert len(final_root.findall('.//w:tbl', NS)) == before_tables
result = io.BytesIO()
with zipfile.ZipFile(result, 'w', zipfile.ZIP_DEFLATED) as archive:
for name, blob in package.items():
archive.writestr(name, blob)
return result.getvalue(), package, before_tables, removed_drawings
def validate(project, data, manifest):
attachments = data['attachments']
assert len({item['id'] for item in attachments}) == len(attachments)
assert len({item['path'] for item in attachments}) == len(attachments)
ids = {item['id'] for item in attachments}
for activity in data['activities']:
assert set(activity['attachmentIds']) <= ids
for item in attachments:
path = project / 'public' / item['path']
assert path.is_file(), str(path)
if item['source'].endswith('.docx'):
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
assert not any(name.startswith('customXml/') or '/comments' in name for name in names)
excluded = {'word/media/' + x for x in EXCLUDE.get(item['id'], {})}
assert not excluded.intersection(names)
root = ET.fromstring(archive.read('word/document.xml'))
for paragraph in root.findall('.//w:p', NS):
text = ''.join(paragraph.xpath('.//w:t/text()', namespaces=NS))
assert not any(regex.search(text) for regex, replacement in PATTERNS), item['id']
assert len(manifest['files']) == 109
assert sum(row['status'] == '待确认,不发布' for row in manifest['files']) == 7
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True)
parser.add_argument('--project', type=Path, required=True)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
source, project = args.source.resolve(), args.project.resolve()
data_path = project / 'src/data/yuanYuanMaterials.json'
manifest_path = project / 'yuan-yuan-material-manifest.json'
data_blob, manifest_blob = data_path.read_bytes(), manifest_path.read_bytes()
data, manifest = json.loads(data_blob), json.loads(manifest_blob)
if args.check:
validate(project, data, manifest)
print('PASS: references, IDs, source counts, selective media exclusions and text redactions')
return
rows = {row['source']: row for row in manifest['files']}
attachments = {item['id']: item for item in data['attachments']}
changes, originals, report = {}, {}, []
# Prepare and verify all output in memory before replacing any published file.
for item in list(data['attachments']):
if not item['source'].endswith('.docx'):
if item['source'].endswith('.doc'):
item['note'] = '旧DOC文字转换版;尚未恢复原表格/图形,待版式转换核验;原件保留,出生信息已略'
rows[item['source']]['note'] = item['note']
rows[item['source']]['layoutStatus'] = '待核验旧DOC版式转换,不冒称完整原版'
continue
src = source / item['source']
original = src.read_bytes()
originals[src] = digest(original)
target = project / 'public' / item['path']
# Read target package before replacement; also save a non-public backup.
existing = target.read_bytes()
with zipfile.ZipFile(io.BytesIO(existing)) as archive:
archive.read('word/document.xml')
blob, package, tables, removed = restore_package(original, item['id'])
changes[target] = blob
restored_media = sorted(name for name in package if name.startswith('word/media/'))
item['title'] = item['title'].replace('(文字整理版)', '(审核展示版)')
item['note'] = '审核展示版;保留原文段落、表格及核阅通过图片,去除批注/元数据及非必要个人信息;原件保留'
excluded = EXCLUDE.get(item['id'], {})
if excluded:
item['note'] += ';已移除' + ('学生名单颁奖照片' if item['id'] == 'yy-4beac9bea263' else '手写签名')
row = rows[item['source']]
previous_note = row.get('note', '')
row.update(status='已接入审核图文副本', note=item['note'], sourceSha256=originals[src], originalTables=tables, restoredTables=tables, restoredImages=len(restored_media), excludedEmbeddedImages=excluded)
if '日期口径待确认' in previous_note:
row['note'] += '。标题为2024学年度,但正文涉及2025年10月活动,日期口径待确认;不推测发布日期。'
row['derivedImages'] = []
for image_name, title in GALLERY.get(item['id'], {}).items():
media_path = 'word/media/' + image_name
image_blob = package[media_path]
embedded_source = item['source'] + '#' + media_path
image_id = 'yy-embedded-' + digest(embedded_source.encode())[:12]
path = 'teachers/yuan-yuan/images/' + image_id + '.jpg'
new = dict(id=image_id, title=title, format='jpg', path=path, source=embedded_source, category=item['category'], note='来自已提供Word的核阅通过内嵌照片,并非新增原始资料')
if image_id not in attachments:
data['attachments'].append(new)
attachments[image_id] = new
else:
attachments[image_id].update(new)
changes[project / 'public' / path] = image_blob
for activity in data['activities']:
if item['id'] in activity['attachmentIds'] and image_id not in activity['attachmentIds']:
activity['attachmentIds'].append(image_id)
row['derivedImages'].append({'part': media_path, 'attachmentId': image_id, 'path': path, 'sha256': digest(image_blob)})
report.append({'id': item['id'], 'source': item['source'], 'tables': tables, 'images': len(restored_media), 'excludedImages': len(excluded), 'removedDrawings': removed})
for resource in data['resources']:
if resource.get('attachment'):
current = attachments[resource['attachment']['id']]
resource['attachment'] = copy.deepcopy(current)
resource['title'] = current['title']
resource['description'] = current.get('note', resource['description'])
# Check source and JSON versions before committing the prepared changes.
assert data_path.read_bytes() == data_blob and manifest_path.read_bytes() == manifest_blob, 'Metadata changed concurrently; retry from latest files'
assert all(digest(path.read_bytes()) == hash_ for path, hash_ in originals.items())
backup = Path(tempfile.mkdtemp(prefix='yuan-before-layout-restore-'))
for i, (path, blob) in enumerate(changes.items()):
if path.exists():
(backup / f'{i}-{path.name}').write_bytes(path.read_bytes())
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
(backup / 'yuanYuanMaterials.json').write_bytes(data_blob)
(backup / 'manifest.json').write_bytes(manifest_blob)
data_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
manifest['documentRestoration'] = {
'method': '保留原DOCX结构,按已核阅图片清单逐项脱敏;不整体剥离图表',
'docxFiles': len(report), 'restoredTables': sum(x['tables'] for x in report),
'restoredEmbeddedImages': sum(x['images'] for x in report),
'excludedEmbeddedImages': sum(x['excludedImages'] for x in report),
'derivedGalleryImages': sum(len(x) for x in GALLERY.values()),
'oldDocStatus': '4份旧DOC仍为文字转换版,未宣称恢复原表格或图形',
'files': report,
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
validate(project, data, manifest)
assert all(digest(path.read_bytes()) == hash_ for path, hash_ in originals.items())
print(json.dumps({'restoredDocx': len(report), 'tables': sum(x['tables'] for x in report), 'embeddedImages': sum(x['images'] for x in report), 'excludedImages': sum(x['excludedImages'] for x in report), 'galleryImages': sum(len(x) for x in GALLERY.values()), 'attachments': len(data['attachments']), 'sourceFiles': len(manifest['files']), 'backup': str(backup)}, ensure_ascii=False))
if __name__ == '__main__':
main()