修正了 GVSDSDK,修改了部分 UI 样式
This commit is contained in:
1 parent
194085a067
commit
5dfea168d3
44 files changed
+1208
-568
No files matched your search
+286
@@ -1,5 +1,291 @@
|
||||
|
||||
|
||||
<section class="container mx-auto px-4 py-16 md:py-24 overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto relative">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute -top-16 -left-16 w-64 h-64 bg-primary/5 dark:bg-blue-900/10 rounded-full blur-3xl -z-10"></div>
|
||||
<div class="absolute -bottom-24 -right-24 w-80 h-80 bg-primary/8 dark:bg-blue-900/10 rounded-full blur-3xl -z-10"></div>
|
||||
|
||||
<!-- 顶部标题区 -->
|
||||
<div class="mb-12 md:mb-16 relative">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-semibold text-primary bg-primary/10 dark:bg-blue-900/20 dark:text-blue-300 rounded-sm border border-primary/20 dark:border-blue-500/20 tracking-wider uppercase">
|
||||
<i class="fa fa-building text-[10px]"></i> Studio Report
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-slate-700/50 rounded-sm border border-gray-200 dark:border-slate-600/50 tracking-wider">
|
||||
<i class="fa fa-calendar text-[10px]"></i> 2025-10-25
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-sm border border-emerald-200 dark:border-emerald-700/30 tracking-wider">
|
||||
<i class="fa fa-check-circle text-[10px]"></i> 已归档
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-[clamp(1.6rem,3.5vw,2.6rem)] font-bold text-text-primary dark:text-white mb-3 leading-tight tracking-tight">
|
||||
银河锋锐软件开发工作室
|
||||
<span class="text-primary dark:text-blue-400">第六次全体会议</span>
|
||||
纪要
|
||||
</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm md:text-base max-w-3xl leading-relaxed">
|
||||
本次会议围绕 <strong class="text-text-primary dark:text-white font-semibold">15 项关键议题</strong> 展开深度讨论,全面梳理工作室现状、部门制改革方向与长期发展战略,由工作室负责人 TermiNexus 主持并发表重要讲话。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区:左侧媒体 + 右侧信息 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-10 items-start">
|
||||
|
||||
<!-- 左栏:视频 + 演讲者 + 关键数据 -->
|
||||
<div class="lg:col-span-5 space-y-5">
|
||||
<!-- 视频卡片 -->
|
||||
<div class="group overflow-hidden rounded-lg border border-gray-200 dark:border-slate-700 shadow-sm hover:shadow-md dark:shadow-black/30 transition-all duration-500 card-hover">
|
||||
<div class="aspect-video bg-gray-50 dark:bg-slate-800 flex flex-col items-center justify-center relative">
|
||||
<div class="text-center px-6">
|
||||
<div class="w-14 h-14 rounded-full bg-gray-100 dark:bg-slate-700 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fa fa-exclamation text-gray-400 dark:text-gray-500 text-xl"></i>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">媒体文件不可用</h4>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">录像文件已损坏或丢失,无法播放</p>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/5 to-transparent pointer-events-none"></div>
|
||||
</div>
|
||||
<div class="px-5 py-4 bg-white dark:bg-slate-800 border-t border-gray-100 dark:border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-text-primary dark:text-white">会议全程录像</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">原始录制:2025-10-25 · 16:07</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded border border-red-100 dark:border-red-800/30">
|
||||
<i class="fa fa-times-circle text-[10px]"></i> 文件丢失
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主持人信息 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-4 flex items-center gap-4 shadow-sm">
|
||||
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-primary/20 dark:border-blue-500/20 flex-shrink-0">
|
||||
<img src="/assets/members/terminexus.jpg" alt="TermiNexus" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-0.5 uppercase tracking-wider">主持 & 主讲</p>
|
||||
<h3 class="text-base font-bold text-text-primary dark:text-white">TermiNexus</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">工作室负责人 · Studio Director</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">会议编号</p>
|
||||
<p class="text-sm font-mono font-bold text-primary dark:text-blue-400">#GT2025(GX-006)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关键数据面板 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm">
|
||||
<div class="px-5 py-3 border-b border-gray-100 dark:border-slate-700 flex items-center gap-2">
|
||||
<i class="fa fa-bar-chart text-primary dark:text-blue-400 text-sm"></i>
|
||||
<span class="text-sm font-semibold text-text-primary dark:text-white">会议关键指标</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 divide-x divide-gray-100 dark:divide-slate-700">
|
||||
<div class="px-4 py-4 text-center">
|
||||
<p class="text-2xl font-bold text-primary dark:text-blue-400">15</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">议题数量</p>
|
||||
</div>
|
||||
<div class="px-4 py-4 text-center">
|
||||
<p class="text-2xl font-bold text-primary dark:text-blue-400">4</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">核心项目</p>
|
||||
</div>
|
||||
<div class="px-4 py-4 text-center">
|
||||
<p class="text-2xl font-bold text-primary dark:text-blue-400">6</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">部门覆盖</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看纪要按钮 -->
|
||||
<button
|
||||
class="w-full bg-primary hover:bg-primary/90 dark:bg-blue-600 dark:hover:bg-blue-500 text-white px-5 py-3 rounded-lg font-medium text-sm transition-all duration-300 flex items-center justify-center gap-2 shadow-sm hover:shadow-md"
|
||||
onclick="window.open('/Activity/Meeting/6/');"
|
||||
>
|
||||
<i class="fa fa-file-text-o"></i>
|
||||
查看完整会议纪要
|
||||
<i class="fa fa-arrow-right text-xs opacity-70"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:议题详情 -->
|
||||
<div class="lg:col-span-7 space-y-4">
|
||||
|
||||
<!-- 区块标题 -->
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-slate-700"></div>
|
||||
<span class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-widest">核心议题摘要</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-slate-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- 议题卡片列表 -->
|
||||
<!-- 议题 01 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm hover:shadow-md dark:hover:shadow-black/30 transition-all duration-300 hover:-translate-y-0.5 card-hover">
|
||||
<div class="flex items-stretch">
|
||||
<div class="w-1 bg-blue-500 flex-shrink-0"></div>
|
||||
<div class="flex-1 p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="text-sm font-bold text-text-primary dark:text-white leading-snug">成员发展与关怀</h4>
|
||||
<span class="flex-shrink-0 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-slate-700 px-2 py-0.5 rounded border border-gray-100 dark:border-slate-600">01</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">重点关注九年级成员发展,强调平衡学业、生活与工作室事务;关注成员身心健康,提醒做好冬季健康防护,体现以人为本的管理理念。</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-blue-50 dark:bg-blue-900/20 text-blue-500 dark:text-blue-400 border border-blue-100 dark:border-blue-800/30">人员管理</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-blue-50 dark:bg-blue-900/20 text-blue-500 dark:text-blue-400 border border-blue-100 dark:border-blue-800/30">成员关怀</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 议题 02 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm hover:shadow-md dark:hover:shadow-black/30 transition-all duration-300 hover:-translate-y-0.5 card-hover">
|
||||
<div class="flex items-stretch">
|
||||
<div class="w-1 bg-indigo-500 flex-shrink-0"></div>
|
||||
<div class="flex-1 p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="text-sm font-bold text-text-primary dark:text-white leading-snug">组织架构改革 · 金字塔制 → 部门制</h4>
|
||||
<span class="flex-shrink-0 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-slate-700 px-2 py-0.5 rounded border border-gray-100 dark:border-slate-600">02</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">推进从金字塔式组委制到扁平化部门制的核心变革,明确各部门分工与职能边界。识别当前痛点:人手短缺、新部门建设滞后、执行效率不足,提出重启人才计划作为核心解决方案。</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-indigo-50 dark:bg-indigo-900/20 text-indigo-500 dark:text-indigo-400 border border-indigo-100 dark:border-indigo-800/30">组织变革</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-indigo-50 dark:bg-indigo-900/20 text-indigo-500 dark:text-indigo-400 border border-indigo-100 dark:border-indigo-800/30">架构优化</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-red-50 dark:bg-red-900/20 text-red-400 dark:text-red-400 border border-red-100 dark:border-red-800/30">人才计划</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 议题 03 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm hover:shadow-md dark:hover:shadow-black/30 transition-all duration-300 hover:-translate-y-0.5 card-hover">
|
||||
<div class="flex items-stretch">
|
||||
<div class="w-1 bg-violet-500 flex-shrink-0"></div>
|
||||
<div class="flex-1 p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="text-sm font-bold text-text-primary dark:text-white leading-snug">部门重建与核心职能界定</h4>
|
||||
<span class="flex-shrink-0 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-slate-700 px-2 py-0.5 rounded border border-gray-100 dark:border-slate-600">03</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">系统梳理现有部门构成及缺岗情况,表彰工程部、美工部核心成员的突出贡献。明确各部门核心职能定位与跨部门协作规范,为后续规模化扩张奠定制度基础。</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-violet-50 dark:bg-violet-900/20 text-violet-500 dark:text-violet-400 border border-violet-100 dark:border-violet-800/30">部门建设</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-violet-50 dark:bg-violet-900/20 text-violet-500 dark:text-violet-400 border border-violet-100 dark:border-violet-800/30">协作规范</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 议题 04 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm hover:shadow-md dark:hover:shadow-black/30 transition-all duration-300 hover:-translate-y-0.5 card-hover">
|
||||
<div class="flex items-stretch">
|
||||
<div class="w-1 bg-sky-500 flex-shrink-0"></div>
|
||||
<div class="flex-1 p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="text-sm font-bold text-text-primary dark:text-white leading-snug">品牌建设与长期战略规划</h4>
|
||||
<span class="flex-shrink-0 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-slate-700 px-2 py-0.5 rounded border border-gray-100 dark:border-slate-600">04</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">探讨工作室名称定位、商标体系与企业文化内核建设;明确项目推进分工、质量保障标准及测试机制,系统优化 GERP 任务追踪、工作复盘与成员考核机制,提出新项目精细化推进与弹性时间管理方案。</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-sky-50 dark:bg-sky-900/20 text-sky-500 dark:text-sky-400 border border-sky-100 dark:border-sky-800/30">品牌战略</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-sky-50 dark:bg-sky-900/20 text-sky-500 dark:text-sky-400 border border-sky-100 dark:border-sky-800/30">质量管理</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-sky-50 dark:bg-sky-900/20 text-sky-500 dark:text-sky-400 border border-sky-100 dark:border-sky-800/30">GERP 优化</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更多议题提示 -->
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<div class="h-px flex-1 bg-gray-100 dark:bg-slate-700/50"></div>
|
||||
<button onclick="window.open('/Activity/Meeting/6/');" class="text-xs text-gray-400 dark:text-gray-500 hover:text-primary dark:hover:text-blue-400 transition-colors flex items-center gap-1.5 group">
|
||||
<i class="fa fa-ellipsis-h"></i>
|
||||
<span>另有 10 项议题,查看完整纪要</span>
|
||||
<i class="fa fa-long-arrow-right text-[10px] opacity-0 group-hover:opacity-100 transition-opacity"></i>
|
||||
</button>
|
||||
<div class="h-px flex-1 bg-gray-100 dark:bg-slate-700/50"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会议结语 · 金句区 -->
|
||||
<div class="mt-16 md:mt-24 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<!-- 金句一 -->
|
||||
<div class="relative bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-8 shadow-sm overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-gradient-to-b from-blue-500 to-indigo-500 rounded-l-xl"></div>
|
||||
<div class="absolute -top-4 -right-4 w-24 h-24 bg-primary/5 dark:bg-blue-500/5 rounded-full blur-2xl"></div>
|
||||
<i class="fa fa-quote-left text-primary/20 dark:text-blue-400/20 text-5xl absolute top-6 right-6"></i>
|
||||
<div class="relative pl-4">
|
||||
<p class="text-base md:text-lg font-semibold text-text-primary dark:text-white leading-relaxed mb-3 font-milky-han">
|
||||
部门制核心是分工明确,更是心往一处想 —— 工程部铸骨,美工部赋魂。
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 font-milky-han">团队的价值不止于项目成果,更在于让每个成员在成长中遇见更好的自己。</p>
|
||||
<div class="flex items-center gap-3 mt-5 pt-4 border-t border-gray-100 dark:border-slate-700">
|
||||
<div class="w-7 h-7 rounded-full overflow-hidden">
|
||||
<img src="/assets/members/terminexus.jpg" alt="" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-text-primary dark:text-white">TermiNexus</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">六会金句 · 2025-10-25</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 金句二 -->
|
||||
<div class="relative bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-8 shadow-sm overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-gradient-to-b from-violet-500 to-sky-500 rounded-l-xl"></div>
|
||||
<div class="absolute -top-4 -right-4 w-24 h-24 bg-violet-500/5 dark:bg-violet-500/5 rounded-full blur-2xl"></div>
|
||||
<i class="fa fa-quote-left text-violet-300/30 dark:text-violet-400/20 text-5xl absolute top-6 right-6"></i>
|
||||
<div class="relative pl-4">
|
||||
<p class="text-base md:text-lg font-semibold text-text-primary dark:text-white leading-relaxed mb-3 font-milky-han">
|
||||
和而不同、创新为主、以人为本
|
||||
</p>
|
||||
<p class="text-sm md:text-base font-medium text-primary/70 dark:text-blue-300 leading-relaxed font-playlist-script">
|
||||
Wishing you eternal happiness and joy, and thank you for bringing vitality to the organization.
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mt-5 pt-4 border-t border-gray-100 dark:border-slate-700">
|
||||
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-blue-400 to-indigo-600 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fa fa-star text-white text-[10px]"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-text-primary dark:text-white font-milky-han">银河锋锐软件开发工作室</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">组织寄语 · 2025-10-25</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部 CTA -->
|
||||
<div class="mt-10 flex flex-col sm:flex-row items-center justify-between gap-4 px-6 py-5 bg-gray-50 dark:bg-slate-800/50 border border-gray-200 dark:border-slate-700 rounded-xl">
|
||||
<div class="text-center sm:text-left">
|
||||
<p class="text-sm font-semibold text-text-primary dark:text-white">本次会议已形成正式文件</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">包含完整决议事项、部门任务分配及后续跟进计划</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex-shrink-0 bg-primary hover:bg-primary/90 dark:bg-blue-600 dark:hover:bg-blue-500 text-white px-6 py-2.5 rounded-lg font-medium text-sm transition-all duration-300 flex items-center gap-2 shadow-sm hover:shadow-md"
|
||||
onclick="window.open('/Activity/Meeting/6/');"
|
||||
>
|
||||
<i class="fa fa-file-text-o"></i>
|
||||
访问完整会议纪要
|
||||
<i class="fa fa-external-link text-xs opacity-70"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card-hover { transition: all .35s cubic-bezier(.25, .8, .25, 1); }
|
||||
.font-milky-han { font-family: "Milky Han", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
.font-playlist-script { font-family: "Playlist Script", cursive; }
|
||||
.text-text-primary { color: #1a1a1a; }
|
||||
.text-primary { color: #165dff; }
|
||||
.bg-primary { background-color: #165dff; }
|
||||
.border-primary { border-color: #165dff; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 1. Anchor: 占位符 -->
|
||||
|
||||
@@ -152,8 +152,8 @@
|
||||
hoverable: true,
|
||||
bordered: true,
|
||||
api: {
|
||||
get: buildApiUrl('/API/FormAPI/Get/Department'),
|
||||
add: buildApiUrl('/API/FormAPI/Add/Department'),
|
||||
get: buildApiUrl('/API/FormAPI/Get/Department', { CompanyUUID: companyUUID }),
|
||||
add: buildApiUrl('/API/FormAPI/Add/Department', { CompanyUUID: companyUUID }),
|
||||
edit: buildApiUrl('/API/FormAPI/Modify/Department/<id>'),
|
||||
modify: buildApiUrl('/API/FormAPI/ModifyItem/Department/<id>'),
|
||||
delete: buildApiUrl('/API/FormAPI/Delete/Department/<id>'),
|
||||
@@ -188,8 +188,8 @@
|
||||
hoverable: true,
|
||||
bordered: true,
|
||||
api: {
|
||||
get: buildApiUrl('/API/FormAPI/Get/Workgroup'),
|
||||
add: buildApiUrl('/API/FormAPI/Add/Workgroup'),
|
||||
get: buildApiUrl('/API/FormAPI/Get/Workgroup', { CompanyUUID: companyUUID }),
|
||||
add: buildApiUrl('/API/FormAPI/Add/Workgroup', { CompanyUUID: companyUUID }),
|
||||
edit: buildApiUrl('/API/FormAPI/Modify/Workgroup/<id>'),
|
||||
modify: buildApiUrl('/API/FormAPI/ModifyItem/Workgroup/<id>'),
|
||||
delete: buildApiUrl('/API/FormAPI/Delete/Workgroup/<id>'),
|
||||
@@ -229,8 +229,8 @@
|
||||
hoverable: true,
|
||||
bordered: true,
|
||||
api: {
|
||||
get: buildApiUrl('/API/FormAPI/Get/UserRole'),
|
||||
add: buildApiUrl('/API/FormAPI/Add/UserRole'),
|
||||
get: buildApiUrl('/API/FormAPI/Get/UserRole', { CompanyUUID: companyUUID }),
|
||||
add: buildApiUrl('/API/FormAPI/Add/UserRole', { CompanyUUID: companyUUID }),
|
||||
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
|
||||
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
|
||||
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{ field: 'CompanyName', title: '公司全称', width: 200, type: 'text' },
|
||||
{ field: 'CompanyShort', title: '公司简称', width: 120, type: 'text' },
|
||||
{ field: 'CompanyStatus', title: '状态', width: 70, type: 'select', options: [{value:0,label:'禁用'},{value:1,label:'启用'}] },
|
||||
{ field: 'ParentCompanyUUID', title: '上级公司', width: 140, type: 'text' },
|
||||
CompanyUtils.epColumn('ParentCompanyUUID', '上级公司', 'Company', { editable: false }),
|
||||
{ field: 'CreateTime', title: '创建时间', width: 160, type: 'datetime', editable: false },
|
||||
],
|
||||
onView: (row) => {
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
{ field: 'DeptName', title: '部门名称', width: 160, type: 'text' },
|
||||
{ field: 'DeptSort', title: '排序', width: 80, type: 'number' },
|
||||
{ field: 'DeptStatus', title: '状态', width: 80, type: 'select', options: [{value:0,label:'禁用'},{value:1,label:'启用'}] },
|
||||
{ field: 'CompanyUUID', title: '所属公司', width: 160, type: 'text', editable: false, hideInForm: true },
|
||||
{ field: 'ParentDeptUUID', title: '上级部门', width: 160, type: 'text' },
|
||||
CompanyUtils.epColumn('CompanyUUID', '所属公司', 'Company', { editable: false, hideInForm: true }),
|
||||
CompanyUtils.epColumn('ParentDeptUUID', '上级部门', 'Department'),
|
||||
{ field: 'CreateTime', title: '创建时间', width: 160, type: 'datetime', editable: false },
|
||||
],
|
||||
onView: (row) => {
|
||||
|
||||
@@ -66,18 +66,24 @@
|
||||
currentData = data;
|
||||
CompanyUtils.setPageTitle(data.WorkgroupName || '工作组详情');
|
||||
|
||||
// 解析 UUID 字段为名称
|
||||
const uuidMap = {};
|
||||
if (data.CompanyUUID) uuidMap.company = { table: 'Company', uuids: [data.CompanyUUID] };
|
||||
const resolved = await CompanyUtils.resolveUUIDs(uuidMap);
|
||||
|
||||
// 渲染基本信息 + 表格容器
|
||||
containerEl.innerHTML = `
|
||||
<div class="cp-detail-section">
|
||||
<h3><i class="fas fa-users-cog"></i> 基本信息</h3>
|
||||
<div class="cp-info-grid">
|
||||
<div class="cp-info-item"><div class="label">工作组名称</div><div class="value">${CompanyUtils.esc(data.WorkgroupName) || '-'}</div></div>
|
||||
<div class="cp-info-item"><div class="label">工作组状态</div><div class="value">${CompanyUtils.statusTag(data.WorkgroupStatus)}</div></div>
|
||||
<div class="cp-info-item"><div class="label">创建时间</div><div class="value">${CompanyUtils.formatTime(data.WorkgroupCreateTime)}</div></div>
|
||||
<div class="cp-info-item"><div class="label">英文名称</div><div class="value">${CompanyUtils.esc(data.WorkgroupEnglishName) || '-'}</div></div>
|
||||
<div class="cp-info-item"><div class="label">所属公司</div><div class="value">${CompanyUtils.esc(resolved.company?.[data.CompanyUUID] || data.CompanyUUID) || '-'}</div></div>
|
||||
<div class="cp-info-item"><div class="label">工作组介绍</div><div class="value">${CompanyUtils.esc(data.WorkgroupIntroduction) || '-'}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cp-detail-section">
|
||||
<h3><i class="fas fa-user-friends"></i> 工作组成员</h3>
|
||||
<h3><i class="fas fa-user-tag"></i> 工作组角色</h3>
|
||||
<div id="member-table-container" style="height:400px;"></div>
|
||||
</div>`;
|
||||
|
||||
@@ -92,10 +98,9 @@
|
||||
memberTable = new TableEngine({
|
||||
container: '#member-table-container',
|
||||
height: '400px',
|
||||
tableName: 'UserRole',
|
||||
tableName: 'WorkgroupRole',
|
||||
columns: [
|
||||
{ field: 'UserRoleUUID', title: '关联UUID', primaryKey: true, editable: false, hideInForm: true },
|
||||
CompanyUtils.epColumn('UserUUID', '用户名', 'User', { editable: false }),
|
||||
{ field: 'WorkgroupRoleUUID', title: '关联UUID', primaryKey: true, editable: false, hideInForm: true },
|
||||
CompanyUtils.epColumn('RoleUUID', '角色名', 'Role'),
|
||||
{ field: 'RoleType', title: '角色类型', type: 'select', options: [{ value: 0, label: '组员' }, { value: 1, label: '组长' }] },
|
||||
{ field: 'CreateTime', title: '创建时间', type: 'datetime', editable: false },
|
||||
@@ -112,16 +117,16 @@
|
||||
hoverable: true,
|
||||
bordered: true,
|
||||
api: {
|
||||
get: buildApiUrl('/API/FormAPI/Get/UserRole', { WorkgroupUUID: workgroupUUID }),
|
||||
add: buildApiUrl('/API/FormAPI/Add/UserRole', { WorkgroupUUID: workgroupUUID }),
|
||||
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
|
||||
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
|
||||
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
|
||||
get: buildApiUrl('/API/FormAPI/Get/WorkgroupRole', { WorkgroupUUID: workgroupUUID }),
|
||||
add: buildApiUrl('/API/FormAPI/Add/WorkgroupRole', { WorkgroupUUID: workgroupUUID }),
|
||||
edit: buildApiUrl('/API/FormAPI/Modify/WorkgroupRole/<id>'),
|
||||
modify: buildApiUrl('/API/FormAPI/ModifyItem/WorkgroupRole/<id>'),
|
||||
delete: buildApiUrl('/API/FormAPI/Delete/WorkgroupRole/<id>'),
|
||||
},
|
||||
onView: (row) => {
|
||||
let url = `/pages/Company/employee-detail.html?UserUUID=${encodeURIComponent(row.UserUUID || '')}`;
|
||||
let url = `/pages/Company/role-detail.html?RoleUUID=${encodeURIComponent(row.RoleUUID || '')}`;
|
||||
if (currentServer !== 'gvsds-master') url += `&server=${encodeURIComponent(currentServer)}`;
|
||||
GERP.Navigate(url, row.UserUUID_label || '员工详情');
|
||||
GERP.Navigate(url, row.RoleUUID_label || '角色详情');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
{ field: 'WorkgroupName', title: '工作组名称', width: 160, type: 'text' },
|
||||
{ field: 'WorkgroupEnglishName', title: '英文名称', width: 160, type: 'text' },
|
||||
{ field: 'WorkgroupIntroduction', title: '工作组介绍', width: 240, type: 'textarea' },
|
||||
CompanyUtils.epColumn('CompanyUUID', '所属公司', 'Company'),
|
||||
],
|
||||
onView: (row) => {
|
||||
console.log('[workgroup] onView row:', row);
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
document.getElementById('ctx-role').textContent = d.current_role_name || d.current_role_uuid || '-';
|
||||
document.getElementById('ctx-company').textContent = d.current_company_uuid || '-';
|
||||
document.getElementById('ctx-tenant').textContent = d.current_tenant_uuid || '-';
|
||||
document.getElementById('ctx-scope').textContent = d.company_scope === false ? '已关闭(特权模式)' : '已开启(公司隔离)';
|
||||
document.getElementById('ctx-company-scope').textContent = d.company_scope === false ? '已关闭(特权模式)' : '已开启(公司隔离)';
|
||||
document.getElementById('ctx-tenant-scope').textContent = d.tenant_scope === false ? '已关闭(跨租户管理员)' : '已开启(租户隔离)';
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 17 KiB |
@@ -43,12 +43,14 @@ window.addEventListener('load', function () {
|
||||
link.target = '_blank';
|
||||
link.classList.add('nav-link', 'btn', 'btn-warning', 'zip');
|
||||
link.style = "padding: 0;";
|
||||
const avatarWrap = document.createElement('div');
|
||||
avatarWrap.style.cssText = 'width:50px;height:50px;border-radius:50%;overflow:hidden;display:inline-block;';
|
||||
const img = document.createElement('img');
|
||||
img.id = 'UserImg';
|
||||
img.width = 50;
|
||||
img.height = 50;
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
|
||||
img.src = imageUrl;
|
||||
link.appendChild(img);
|
||||
avatarWrap.appendChild(img);
|
||||
link.appendChild(avatarWrap);
|
||||
userDiv.appendChild(link);
|
||||
const userFrame = document.getElementById('UserFrame');
|
||||
userFrame.innerHTML = '';
|
||||
@@ -118,30 +120,3 @@ console.log("\n");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -338,311 +338,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<section class="container mx-auto px-4 py-16 md:py-24 overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto relative">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute -top-16 -left-16 w-64 h-64 bg-primary/5 dark:bg-blue-900/10 rounded-full blur-3xl -z-10"></div>
|
||||
<div class="absolute -bottom-24 -right-24 w-80 h-80 bg-primary/8 dark:bg-blue-900/10 rounded-full blur-3xl -z-10"></div>
|
||||
|
||||
<!-- 顶部标题区 -->
|
||||
<div class="mb-12 md:mb-16 relative">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-semibold text-primary bg-primary/10 dark:bg-blue-900/20 dark:text-blue-300 rounded-sm border border-primary/20 dark:border-blue-500/20 tracking-wider uppercase">
|
||||
<i class="fa fa-building text-[10px]"></i> Studio Report
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-slate-700/50 rounded-sm border border-gray-200 dark:border-slate-600/50 tracking-wider">
|
||||
<i class="fa fa-calendar text-[10px]"></i> 2025-10-25
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-sm border border-emerald-200 dark:border-emerald-700/30 tracking-wider">
|
||||
<i class="fa fa-check-circle text-[10px]"></i> 已归档
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-[clamp(1.6rem,3.5vw,2.6rem)] font-bold text-text-primary dark:text-white mb-3 leading-tight tracking-tight">
|
||||
银河锋锐软件开发工作室
|
||||
<span class="text-primary dark:text-blue-400">第六次全体会议</span>
|
||||
纪要
|
||||
</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm md:text-base max-w-3xl leading-relaxed">
|
||||
本次会议围绕 <strong class="text-text-primary dark:text-white font-semibold">15 项关键议题</strong> 展开深度讨论,全面梳理工作室现状、部门制改革方向与长期发展战略,由工作室负责人 TermiNexus 主持并发表重要讲话。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区:左侧媒体 + 右侧信息 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-10 items-start">
|
||||
|
||||
<!-- 左栏:视频 + 演讲者 + 关键数据 -->
|
||||
<div class="lg:col-span-5 space-y-5">
|
||||
<!-- 视频卡片 -->
|
||||
<div class="group overflow-hidden rounded-lg border border-gray-200 dark:border-slate-700 shadow-sm hover:shadow-md dark:shadow-black/30 transition-all duration-500 card-hover">
|
||||
<div class="aspect-video bg-gray-50 dark:bg-slate-800 flex flex-col items-center justify-center relative">
|
||||
<div class="text-center px-6">
|
||||
<div class="w-14 h-14 rounded-full bg-gray-100 dark:bg-slate-700 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fa fa-exclamation text-gray-400 dark:text-gray-500 text-xl"></i>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">媒体文件不可用</h4>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">录像文件已损坏或丢失,无法播放</p>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/5 to-transparent pointer-events-none"></div>
|
||||
</div>
|
||||
<div class="px-5 py-4 bg-white dark:bg-slate-800 border-t border-gray-100 dark:border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-text-primary dark:text-white">会议全程录像</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">原始录制:2025-10-25 · 16:07</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded border border-red-100 dark:border-red-800/30">
|
||||
<i class="fa fa-times-circle text-[10px]"></i> 文件丢失
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主持人信息 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-4 flex items-center gap-4 shadow-sm">
|
||||
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-primary/20 dark:border-blue-500/20 flex-shrink-0">
|
||||
<img src="/assets/members/terminexus.jpg" alt="TermiNexus" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-0.5 uppercase tracking-wider">主持 & 主讲</p>
|
||||
<h3 class="text-base font-bold text-text-primary dark:text-white">TermiNexus</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">工作室负责人 · Studio Director</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">会议编号</p>
|
||||
<p class="text-sm font-mono font-bold text-primary dark:text-blue-400">#GT2025(GX-006)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关键数据面板 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm">
|
||||
<div class="px-5 py-3 border-b border-gray-100 dark:border-slate-700 flex items-center gap-2">
|
||||
<i class="fa fa-bar-chart text-primary dark:text-blue-400 text-sm"></i>
|
||||
<span class="text-sm font-semibold text-text-primary dark:text-white">会议关键指标</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 divide-x divide-gray-100 dark:divide-slate-700">
|
||||
<div class="px-4 py-4 text-center">
|
||||
<p class="text-2xl font-bold text-primary dark:text-blue-400">15</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">议题数量</p>
|
||||
</div>
|
||||
<div class="px-4 py-4 text-center">
|
||||
<p class="text-2xl font-bold text-primary dark:text-blue-400">4</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">核心项目</p>
|
||||
</div>
|
||||
<div class="px-4 py-4 text-center">
|
||||
<p class="text-2xl font-bold text-primary dark:text-blue-400">6</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">部门覆盖</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看纪要按钮 -->
|
||||
<button
|
||||
class="w-full bg-primary hover:bg-primary/90 dark:bg-blue-600 dark:hover:bg-blue-500 text-white px-5 py-3 rounded-lg font-medium text-sm transition-all duration-300 flex items-center justify-center gap-2 shadow-sm hover:shadow-md"
|
||||
onclick="window.open('/Activity/Meeting/6/');"
|
||||
>
|
||||
<i class="fa fa-file-text-o"></i>
|
||||
查看完整会议纪要
|
||||
<i class="fa fa-arrow-right text-xs opacity-70"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:议题详情 -->
|
||||
<div class="lg:col-span-7 space-y-4">
|
||||
|
||||
<!-- 区块标题 -->
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-slate-700"></div>
|
||||
<span class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-widest">核心议题摘要</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-slate-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- 议题卡片列表 -->
|
||||
<!-- 议题 01 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm hover:shadow-md dark:hover:shadow-black/30 transition-all duration-300 hover:-translate-y-0.5 card-hover">
|
||||
<div class="flex items-stretch">
|
||||
<div class="w-1 bg-blue-500 flex-shrink-0"></div>
|
||||
<div class="flex-1 p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="text-sm font-bold text-text-primary dark:text-white leading-snug">成员发展与关怀</h4>
|
||||
<span class="flex-shrink-0 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-slate-700 px-2 py-0.5 rounded border border-gray-100 dark:border-slate-600">01</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">重点关注九年级成员发展,强调平衡学业、生活与工作室事务;关注成员身心健康,提醒做好冬季健康防护,体现以人为本的管理理念。</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-blue-50 dark:bg-blue-900/20 text-blue-500 dark:text-blue-400 border border-blue-100 dark:border-blue-800/30">人员管理</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-blue-50 dark:bg-blue-900/20 text-blue-500 dark:text-blue-400 border border-blue-100 dark:border-blue-800/30">成员关怀</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 议题 02 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm hover:shadow-md dark:hover:shadow-black/30 transition-all duration-300 hover:-translate-y-0.5 card-hover">
|
||||
<div class="flex items-stretch">
|
||||
<div class="w-1 bg-indigo-500 flex-shrink-0"></div>
|
||||
<div class="flex-1 p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="text-sm font-bold text-text-primary dark:text-white leading-snug">组织架构改革 · 金字塔制 → 部门制</h4>
|
||||
<span class="flex-shrink-0 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-slate-700 px-2 py-0.5 rounded border border-gray-100 dark:border-slate-600">02</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">推进从金字塔式组委制到扁平化部门制的核心变革,明确各部门分工与职能边界。识别当前痛点:人手短缺、新部门建设滞后、执行效率不足,提出重启人才计划作为核心解决方案。</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-indigo-50 dark:bg-indigo-900/20 text-indigo-500 dark:text-indigo-400 border border-indigo-100 dark:border-indigo-800/30">组织变革</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-indigo-50 dark:bg-indigo-900/20 text-indigo-500 dark:text-indigo-400 border border-indigo-100 dark:border-indigo-800/30">架构优化</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-red-50 dark:bg-red-900/20 text-red-400 dark:text-red-400 border border-red-100 dark:border-red-800/30">人才计划</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 议题 03 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm hover:shadow-md dark:hover:shadow-black/30 transition-all duration-300 hover:-translate-y-0.5 card-hover">
|
||||
<div class="flex items-stretch">
|
||||
<div class="w-1 bg-violet-500 flex-shrink-0"></div>
|
||||
<div class="flex-1 p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="text-sm font-bold text-text-primary dark:text-white leading-snug">部门重建与核心职能界定</h4>
|
||||
<span class="flex-shrink-0 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-slate-700 px-2 py-0.5 rounded border border-gray-100 dark:border-slate-600">03</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">系统梳理现有部门构成及缺岗情况,表彰工程部、美工部核心成员的突出贡献。明确各部门核心职能定位与跨部门协作规范,为后续规模化扩张奠定制度基础。</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-violet-50 dark:bg-violet-900/20 text-violet-500 dark:text-violet-400 border border-violet-100 dark:border-violet-800/30">部门建设</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-violet-50 dark:bg-violet-900/20 text-violet-500 dark:text-violet-400 border border-violet-100 dark:border-violet-800/30">协作规范</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 议题 04 -->
|
||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden shadow-sm hover:shadow-md dark:hover:shadow-black/30 transition-all duration-300 hover:-translate-y-0.5 card-hover">
|
||||
<div class="flex items-stretch">
|
||||
<div class="w-1 bg-sky-500 flex-shrink-0"></div>
|
||||
<div class="flex-1 p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="text-sm font-bold text-text-primary dark:text-white leading-snug">品牌建设与长期战略规划</h4>
|
||||
<span class="flex-shrink-0 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-slate-700 px-2 py-0.5 rounded border border-gray-100 dark:border-slate-600">04</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">探讨工作室名称定位、商标体系与企业文化内核建设;明确项目推进分工、质量保障标准及测试机制,系统优化 GERP 任务追踪、工作复盘与成员考核机制,提出新项目精细化推进与弹性时间管理方案。</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-sky-50 dark:bg-sky-900/20 text-sky-500 dark:text-sky-400 border border-sky-100 dark:border-sky-800/30">品牌战略</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-sky-50 dark:bg-sky-900/20 text-sky-500 dark:text-sky-400 border border-sky-100 dark:border-sky-800/30">质量管理</span>
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-sm bg-sky-50 dark:bg-sky-900/20 text-sky-500 dark:text-sky-400 border border-sky-100 dark:border-sky-800/30">GERP 优化</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更多议题提示 -->
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<div class="h-px flex-1 bg-gray-100 dark:bg-slate-700/50"></div>
|
||||
<button onclick="window.open('/Activity/Meeting/6/');" class="text-xs text-gray-400 dark:text-gray-500 hover:text-primary dark:hover:text-blue-400 transition-colors flex items-center gap-1.5 group">
|
||||
<i class="fa fa-ellipsis-h"></i>
|
||||
<span>另有 10 项议题,查看完整纪要</span>
|
||||
<i class="fa fa-long-arrow-right text-[10px] opacity-0 group-hover:opacity-100 transition-opacity"></i>
|
||||
</button>
|
||||
<div class="h-px flex-1 bg-gray-100 dark:bg-slate-700/50"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会议结语 · 金句区 -->
|
||||
<div class="mt-16 md:mt-24 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<!-- 金句一 -->
|
||||
<div class="relative bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-8 shadow-sm overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-gradient-to-b from-blue-500 to-indigo-500 rounded-l-xl"></div>
|
||||
<div class="absolute -top-4 -right-4 w-24 h-24 bg-primary/5 dark:bg-blue-500/5 rounded-full blur-2xl"></div>
|
||||
<i class="fa fa-quote-left text-primary/20 dark:text-blue-400/20 text-5xl absolute top-6 right-6"></i>
|
||||
<div class="relative pl-4">
|
||||
<p class="text-base md:text-lg font-semibold text-text-primary dark:text-white leading-relaxed mb-3 font-milky-han">
|
||||
部门制核心是分工明确,更是心往一处想 —— 工程部铸骨,美工部赋魂。
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 font-milky-han">团队的价值不止于项目成果,更在于让每个成员在成长中遇见更好的自己。</p>
|
||||
<div class="flex items-center gap-3 mt-5 pt-4 border-t border-gray-100 dark:border-slate-700">
|
||||
<div class="w-7 h-7 rounded-full overflow-hidden">
|
||||
<img src="/assets/members/terminexus.jpg" alt="" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-text-primary dark:text-white">TermiNexus</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">六会金句 · 2025-10-25</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 金句二 -->
|
||||
<div class="relative bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-8 shadow-sm overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-gradient-to-b from-violet-500 to-sky-500 rounded-l-xl"></div>
|
||||
<div class="absolute -top-4 -right-4 w-24 h-24 bg-violet-500/5 dark:bg-violet-500/5 rounded-full blur-2xl"></div>
|
||||
<i class="fa fa-quote-left text-violet-300/30 dark:text-violet-400/20 text-5xl absolute top-6 right-6"></i>
|
||||
<div class="relative pl-4">
|
||||
<p class="text-base md:text-lg font-semibold text-text-primary dark:text-white leading-relaxed mb-3 font-milky-han">
|
||||
和而不同、创新为主、以人为本
|
||||
</p>
|
||||
<p class="text-sm md:text-base font-medium text-primary/70 dark:text-blue-300 leading-relaxed font-playlist-script">
|
||||
Wishing you eternal happiness and joy, and thank you for bringing vitality to the organization.
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mt-5 pt-4 border-t border-gray-100 dark:border-slate-700">
|
||||
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-blue-400 to-indigo-600 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fa fa-star text-white text-[10px]"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-text-primary dark:text-white font-milky-han">银河锋锐软件开发工作室</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">组织寄语 · 2025-10-25</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部 CTA -->
|
||||
<div class="mt-10 flex flex-col sm:flex-row items-center justify-between gap-4 px-6 py-5 bg-gray-50 dark:bg-slate-800/50 border border-gray-200 dark:border-slate-700 rounded-xl">
|
||||
<div class="text-center sm:text-left">
|
||||
<p class="text-sm font-semibold text-text-primary dark:text-white">本次会议已形成正式文件</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">包含完整决议事项、部门任务分配及后续跟进计划</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex-shrink-0 bg-primary hover:bg-primary/90 dark:bg-blue-600 dark:hover:bg-blue-500 text-white px-6 py-2.5 rounded-lg font-medium text-sm transition-all duration-300 flex items-center gap-2 shadow-sm hover:shadow-md"
|
||||
onclick="window.open('/Activity/Meeting/6/');"
|
||||
>
|
||||
<i class="fa fa-file-text-o"></i>
|
||||
访问完整会议纪要
|
||||
<i class="fa fa-external-link text-xs opacity-70"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card-hover { transition: all .35s cubic-bezier(.25, .8, .25, 1); }
|
||||
.font-milky-han { font-family: "Milky Han", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
.font-playlist-script { font-family: "Playlist Script", cursive; }
|
||||
.text-text-primary { color: #1a1a1a; }
|
||||
.text-primary { color: #165dff; }
|
||||
.bg-primary { background-color: #165dff; }
|
||||
.border-primary { border-color: #165dff; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<section class="relative py-16 md:py-24 px-4 md:px-8 overflow-hidden" aria-labelledby="skills-section-title">
|
||||
<div class="container mx-auto relative z-10">
|
||||
<div class="mb-12 md:mb-16 reveal" data-reveal="fade-up">
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+335
-58
@@ -12,7 +12,7 @@ from gvsds.apps.gerp.models import (
|
||||
Tenant, CompanyInfo, Company, Department, Role, UserRole, RolePermission,
|
||||
Permission, Menu, UserABAC, ItemReference, Backpack,
|
||||
WorkOrder, Message, FileAsset, Password, UserIDCard, PersonalPage,
|
||||
DepartmentSMTM, Workgroup, SimulatedTrigger,
|
||||
DepartmentSMTM, Workgroup, WorkgroupRole, SimulatedTrigger,
|
||||
CashReceiptsPaymentsLedger, Asset, Liability, UserSettings,
|
||||
PositionTemplate, PositionTemplatePermission, Position, UserDept,
|
||||
AuditLog,
|
||||
@@ -94,6 +94,64 @@ def create_default_role_for_user(user, admin_user=None):
|
||||
pass
|
||||
|
||||
|
||||
def create_tenant_admin_role(tenant):
|
||||
"""为租户(集团)创建"集团超级管理员"角色并分配 admin:tenant 权限。
|
||||
|
||||
在租户审核通过时调用,确保每个集团都有一个租户内管理员角色。
|
||||
该角色拥有 admin:tenant 权限:
|
||||
- IsAdmin 判定为 True(关闭公司隔离,可跨公司管理本租户事务)
|
||||
- IsSuperAdmin 判定为 False(保留租户隔离,仅管理本租户)
|
||||
- IsTenantAdmin 判定为 True
|
||||
|
||||
与跨租户管理员(GERP 测试员,持有 admin:super)形成两级管理员体系。
|
||||
|
||||
Args:
|
||||
tenant: Tenant 模型实例(需有 TenantUUID 属性)
|
||||
|
||||
Returns:
|
||||
Role 实例(已创建或已存在)
|
||||
"""
|
||||
import uuid as _uuid
|
||||
from gvsds.apps.gerp.models import Role, Permission, RolePermission
|
||||
|
||||
SYSTEM_COMPANY_UUID = _uuid.UUID('00000000-0000-0000-0000-000000000001').bytes
|
||||
tenant_uuid = tenant.TenantUUID
|
||||
if hasattr(tenant_uuid, 'bytes'):
|
||||
tenant_uuid = tenant_uuid.bytes
|
||||
|
||||
# 查找或创建"集团超级管理员"角色
|
||||
role = Role.objects.filter(
|
||||
TenantUUID=tenant_uuid,
|
||||
RoleName='集团超级管理员',
|
||||
).first()
|
||||
if role:
|
||||
return role
|
||||
|
||||
role = Role.objects.create(
|
||||
RoleUUID=_uuid.uuid4().bytes,
|
||||
TenantUUID=tenant_uuid,
|
||||
RoleName='集团超级管理员',
|
||||
RoleStatus=1,
|
||||
AssignScope=0,
|
||||
RoleDesc='集团超级管理员,管理本租户下所有事务',
|
||||
)
|
||||
|
||||
# 分配 admin:tenant 权限(租户内管理员:关闭公司隔离,保留租户隔离)
|
||||
admin_perm = Permission.objects.filter(
|
||||
PermCode='admin:tenant',
|
||||
PermStatus=1,
|
||||
).first()
|
||||
if admin_perm:
|
||||
RolePermission.objects.create(
|
||||
RolePermUUID=_uuid.uuid4().bytes,
|
||||
RoleUUID=role.RoleUUID,
|
||||
PermUUID=admin_perm.PermUUID,
|
||||
CompanyUUID=SYSTEM_COMPANY_UUID,
|
||||
)
|
||||
|
||||
return role
|
||||
|
||||
|
||||
def _pascalize_record(record, model=None):
|
||||
result = {}
|
||||
column_map = {}
|
||||
@@ -105,7 +163,11 @@ def _pascalize_record(record, model=None):
|
||||
column_map[f.attname] = f.db_column
|
||||
for key, val in record.items():
|
||||
out_key = column_map.get(key, key)
|
||||
if isinstance(val, bytes) and len(val) == 16:
|
||||
# UUIDObj 处理:gvsdsdk UUIDField 返回 UUIDObj 对象(非 bytes),
|
||||
# 需转为字符串以便 JSON 序列化,否则前端收不到数据
|
||||
if val is not None and val.__class__.__name__ == 'UUIDObj':
|
||||
val = val.str
|
||||
elif isinstance(val, bytes) and len(val) == 16:
|
||||
try:
|
||||
val = str(uuid.UUID(bytes=val))
|
||||
except ValueError:
|
||||
@@ -146,6 +208,7 @@ class FormService:
|
||||
'PersonalPage': PersonalPage,
|
||||
'DepartmentSMTM': DepartmentSMTM,
|
||||
'Workgroup': Workgroup,
|
||||
'WorkgroupRole': WorkgroupRole,
|
||||
'SimulatedTrigger': SimulatedTrigger,
|
||||
'CashReceiptsPaymentsLedger': CashReceiptsPaymentsLedger,
|
||||
'Asset': Asset,
|
||||
@@ -216,22 +279,33 @@ class FormService:
|
||||
self.current_tenant_uuid = current_tenant_uuid
|
||||
else:
|
||||
self.current_tenant_uuid = current_tenant_uuid
|
||||
# 强公司隔离:默认启用,仅当 admin_verify=True 且用户是管理员时才关闭
|
||||
if admin_verify and self._is_admin(user):
|
||||
# 公司隔离:管理员(admin:super 或 admin:tenant)自动关闭,普通用户启用
|
||||
# 租户隔离:仅跨租户管理员(admin:super)关闭,租户内管理员(admin:tenant)和普通用户启用
|
||||
if self._is_super_admin(user):
|
||||
self.company_scope = False
|
||||
self.tenant_scope = False
|
||||
elif self._is_admin(user):
|
||||
self.company_scope = False
|
||||
self.tenant_scope = True
|
||||
else:
|
||||
self.company_scope = True
|
||||
self.tenant_scope = True
|
||||
import logging
|
||||
logging.info('[FormService] __init__: user=%s, company_scope=%s, current_company_uuid=%s, current_tenant_uuid=%s',
|
||||
user, self.company_scope,
|
||||
logging.info('[FormService] __init__: user=%s, company_scope=%s, tenant_scope=%s, current_company_uuid=%s, current_tenant_uuid=%s',
|
||||
user, self.company_scope, self.tenant_scope,
|
||||
self.current_company_uuid.hex() if isinstance(self.current_company_uuid, bytes) else self.current_company_uuid,
|
||||
self.current_tenant_uuid.hex() if isinstance(self.current_tenant_uuid, bytes) else self.current_tenant_uuid)
|
||||
|
||||
def _is_admin(self, user):
|
||||
"""判断用户是否为管理员(委托给 gvsdsdk.auth.admin_service.IsAdmin)"""
|
||||
"""判断用户是否为管理员(admin:super 或 admin:tenant,委托给 IsAdmin)"""
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
return IsAdmin(user=user, role_uuid=self.current_role_uuid)
|
||||
|
||||
def _is_super_admin(self, user):
|
||||
"""判断用户是否为跨租户管理员(仅 admin:super,委托给 IsSuperAdmin)"""
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
return IsSuperAdmin(user=user, role_uuid=self.current_role_uuid)
|
||||
|
||||
def _build_field_map(self, model):
|
||||
pascal_map = {}
|
||||
for f in model._meta.get_fields():
|
||||
@@ -279,11 +353,81 @@ class FormService:
|
||||
return result
|
||||
|
||||
def _enrich_entity_labels(self, page_qs, ep_fields, records):
|
||||
"""为 records 中的 entityPicker 字段添加 _label 后缀字段,以及 extraFields"""
|
||||
"""为 records 中的 entityPicker 字段添加 _label 后缀字段,以及 extraFields
|
||||
|
||||
支持两种字段类型:
|
||||
- ForeignKey:getattr(obj, field_name) 返回关联模型实例,直接取 label_field
|
||||
- UUIDField:getattr(obj, field_name) 返回 UUIDObj,需批量查询关联表获取名称
|
||||
"""
|
||||
try:
|
||||
obj_list = list(page_qs)
|
||||
except Exception:
|
||||
return records
|
||||
if not obj_list:
|
||||
return records
|
||||
|
||||
# 预构建 UUIDField 类型的 UUID → label 映射(批量查询优化性能)
|
||||
# key: (entity_name, label_field), value: {uuid_bytes: label}
|
||||
uuid_label_cache = {}
|
||||
uuid_extra_cache = {} # key: (entity_name, extra_field), value: {uuid_bytes: value}
|
||||
|
||||
for ep_info in ep_fields:
|
||||
field_name = ep_info['field_name']
|
||||
entity_name = ep_info['entity_name']
|
||||
label_field = ep_info['label_field']
|
||||
extra_fields = ep_info.get('extra_fields', [])
|
||||
value_field = ep_info['value_field']
|
||||
|
||||
# 判断字段类型
|
||||
field_obj = None
|
||||
for f in obj_list[0]._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == field_name:
|
||||
field_obj = f
|
||||
break
|
||||
|
||||
# 仅对 UUIDField(非主键)类型批量查询;ForeignKey 通过 getattr 直接获取
|
||||
if not (field_obj and field_obj.__class__.__name__ == 'UUIDField'
|
||||
and not getattr(field_obj, 'primary_key', False)):
|
||||
continue
|
||||
|
||||
# 收集所有 UUID 值(转为 bytes)
|
||||
uuid_values = set()
|
||||
for obj in obj_list:
|
||||
val = getattr(obj, field_name, None)
|
||||
if val is not None:
|
||||
if hasattr(val, 'bytes'):
|
||||
uuid_values.add(val.bytes)
|
||||
elif isinstance(val, str):
|
||||
try:
|
||||
uuid_values.add(uuid.UUID(val).bytes)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not uuid_values:
|
||||
continue
|
||||
|
||||
related_model = self.TABLE_MAP.get(entity_name)
|
||||
if not related_model:
|
||||
continue
|
||||
|
||||
# 批量查询关联表
|
||||
try:
|
||||
related_qs = related_model.objects.filter(**{f'{value_field}__in': uuid_values})
|
||||
for r in related_qs:
|
||||
pk_val = getattr(r, value_field, None)
|
||||
pk_bytes = pk_val.bytes if hasattr(pk_val, 'bytes') else pk_val
|
||||
if pk_bytes is None:
|
||||
continue
|
||||
label = getattr(r, label_field, None)
|
||||
if label is not None:
|
||||
uuid_label_cache.setdefault((entity_name, label_field), {})[pk_bytes] = label
|
||||
for ef in extra_fields:
|
||||
ef_val = getattr(r, ef, None)
|
||||
if ef_val is not None:
|
||||
uuid_extra_cache.setdefault((entity_name, ef), {})[pk_bytes] = ef_val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for i, obj in enumerate(obj_list):
|
||||
if i >= len(records):
|
||||
break
|
||||
@@ -291,25 +435,54 @@ class FormService:
|
||||
field_name = ep_info['field_name']
|
||||
label_field = ep_info['label_field']
|
||||
extra_fields = ep_info.get('extra_fields', [])
|
||||
entity_name = ep_info['entity_name']
|
||||
|
||||
# 判断字段类型
|
||||
field_obj = None
|
||||
for f in obj._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == field_name:
|
||||
field_obj = f
|
||||
break
|
||||
|
||||
try:
|
||||
related_obj = getattr(obj, field_name, None)
|
||||
if related_obj is not None:
|
||||
# 获取 db_column 作为 key 前缀
|
||||
db_col = None
|
||||
for f in obj._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == field_name and hasattr(f, 'db_column'):
|
||||
db_col = f.db_column or f.name
|
||||
break
|
||||
key_prefix = db_col or field_name
|
||||
# label 字段
|
||||
label = getattr(related_obj, label_field, None)
|
||||
# 获取 db_column 作为 key 前缀
|
||||
db_col = None
|
||||
for f in obj._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == field_name and hasattr(f, 'db_column'):
|
||||
db_col = f.db_column or f.name
|
||||
break
|
||||
key_prefix = db_col or field_name
|
||||
|
||||
is_uuid_field = (field_obj
|
||||
and field_obj.__class__.__name__ == 'UUIDField'
|
||||
and not getattr(field_obj, 'primary_key', False))
|
||||
|
||||
if is_uuid_field:
|
||||
# UUIDField 类型:从缓存查找
|
||||
val = getattr(obj, field_name, None)
|
||||
if val is None:
|
||||
continue
|
||||
val_bytes = val.bytes if hasattr(val, 'bytes') else val
|
||||
if val_bytes is None:
|
||||
continue
|
||||
label = uuid_label_cache.get((entity_name, label_field), {}).get(val_bytes)
|
||||
if label is not None:
|
||||
records[i][key_prefix + '_label'] = str(label)
|
||||
# extraFields 字段
|
||||
for ef in extra_fields:
|
||||
ef_val = getattr(related_obj, ef, None)
|
||||
ef_val = uuid_extra_cache.get((entity_name, ef), {}).get(val_bytes)
|
||||
if ef_val is not None:
|
||||
records[i][key_prefix + '_' + ef] = ef_val
|
||||
else:
|
||||
# ForeignKey 类型:直接获取关联实例
|
||||
related_obj = getattr(obj, field_name, None)
|
||||
if related_obj is not None:
|
||||
label = getattr(related_obj, label_field, None)
|
||||
if label is not None:
|
||||
records[i][key_prefix + '_label'] = str(label)
|
||||
for ef in extra_fields:
|
||||
ef_val = getattr(related_obj, ef, None)
|
||||
if ef_val is not None:
|
||||
records[i][key_prefix + '_' + ef] = ef_val
|
||||
except Exception:
|
||||
pass
|
||||
return records
|
||||
@@ -605,19 +778,13 @@ class FormService:
|
||||
except Exception:
|
||||
return {'records': [], 'total': 0}
|
||||
|
||||
# 强公司隔离:非管理员自动启用,仅返回当前公司/租户数据
|
||||
# 公司隔离:admin:super/admin:tenant 关闭,普通用户启用
|
||||
# 租户隔离:仅 admin:super 关闭,admin:tenant/普通用户启用
|
||||
# 系统级表(Menu/Permission)豁免隔离
|
||||
if self.company_scope and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||
# 租户隔离:Tenant 表按 TenantUUID 过滤,只看自己所属租户
|
||||
if table_name == 'Tenant':
|
||||
if not self.current_tenant_uuid:
|
||||
import logging
|
||||
logging.warning('[FormService] Tenant query but current_tenant_uuid is None, user=%s', self.user)
|
||||
return {'records': [], 'total': 0, 'page': 1, 'PageSize': 20}
|
||||
qs = qs.filter(TenantUUID=self.current_tenant_uuid)
|
||||
field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||
if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
# 公司隔离:有 CompanyUUID 字段的表按当前公司过滤
|
||||
elif 'CompanyUUID' in field_names:
|
||||
if self.company_scope and 'CompanyUUID' in field_names:
|
||||
if not self.current_company_uuid:
|
||||
# 无公司上下文时返回空结果
|
||||
import logging
|
||||
@@ -634,22 +801,38 @@ class FormService:
|
||||
else:
|
||||
# UUIDField(包括 Company 表自身的主键)
|
||||
qs = qs.filter(CompanyUUID=self.current_company_uuid)
|
||||
# 租户隔离:有 TenantUUID 字段但无 CompanyUUID 的表(Role/CompanyInfo 等)按租户过滤
|
||||
elif 'TenantUUID' in field_names:
|
||||
# 租户隔离:admin:super 关闭,admin:tenant/普通用户启用
|
||||
# 覆盖三种表结构:
|
||||
# 1. Tenant 表自身:按 TenantUUID 主键过滤
|
||||
# 2. 有 TenantUUID 字段的表(Role/CompanyInfo 等):直接按 TenantUUID 过滤
|
||||
# 3. 有 CompanyUUID ForeignKey 但无 TenantUUID 的表(Department/Workgroup):
|
||||
# 通过 Company 关联跨表过滤到当前租户
|
||||
elif self.tenant_scope and (table_name == 'Tenant' or 'TenantUUID' in field_names or 'CompanyUUID' in field_names):
|
||||
if not self.current_tenant_uuid:
|
||||
import logging
|
||||
logging.warning('[FormService] tenant scope but current_tenant_uuid is None, user=%s, table=%s', self.user, table_name)
|
||||
return {'records': [], 'total': 0, 'page': 1, 'PageSize': 20}
|
||||
# 判断 TenantUUID 字段类型:ForeignKey 用 _id 后缀
|
||||
tenant_field = None
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == 'TenantUUID':
|
||||
tenant_field = f
|
||||
break
|
||||
if tenant_field and tenant_field.__class__.__name__ == 'ForeignKey':
|
||||
qs = qs.filter(TenantUUID_id=self.current_tenant_uuid)
|
||||
if table_name == 'Tenant' or 'TenantUUID' in field_names:
|
||||
# 表自身有 TenantUUID 字段:直接过滤
|
||||
tenant_field = None
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == 'TenantUUID':
|
||||
tenant_field = f
|
||||
break
|
||||
if tenant_field and tenant_field.__class__.__name__ == 'ForeignKey':
|
||||
qs = qs.filter(TenantUUID_id=self.current_tenant_uuid)
|
||||
else:
|
||||
qs = qs.filter(TenantUUID=self.current_tenant_uuid)
|
||||
else:
|
||||
qs = qs.filter(TenantUUID=self.current_tenant_uuid)
|
||||
# 有 CompanyUUID ForeignKey 但无 TenantUUID:通过 Company 关联跨表过滤
|
||||
company_field = None
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == 'CompanyUUID':
|
||||
company_field = f
|
||||
break
|
||||
if company_field and company_field.__class__.__name__ == 'ForeignKey':
|
||||
qs = qs.filter(CompanyUUID__TenantUUID=self.current_tenant_uuid)
|
||||
# UUIDField 类型的 CompanyUUID(如 Company 表自身)必有 TenantUUID 字段,已走上面分支
|
||||
|
||||
# 角色作用域过滤:针对 User/UserRole/Order 表按角色差异化可见范围
|
||||
# admin 看全部,dept_manager 看本部门,workgroup_lead 看本工作组,member 只看自己
|
||||
@@ -711,10 +894,21 @@ class FormService:
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
# 识别 entityPicker 字段,使用 select_related 预加载关联实体
|
||||
# 注意:select_related 只能用于 ForeignKey,UUIDField 类型的 entityPicker 不能预加载
|
||||
ep_fields = self._get_entity_picker_fields(model)
|
||||
if ep_fields:
|
||||
fk_names = [f['field_name'] for f in ep_fields
|
||||
if f['field_name'] not in ('RoleUUID', 'PermUUID', 'PositionTemplateUUID', 'PositionUUID')]
|
||||
excluded = {'RoleUUID', 'PermUUID', 'PositionTemplateUUID', 'PositionUUID'}
|
||||
fk_names = []
|
||||
for ep_info in ep_fields:
|
||||
fn = ep_info['field_name']
|
||||
if fn in excluded:
|
||||
continue
|
||||
# 只对 ForeignKey 字段使用 select_related,避免 UUIDField 触发 FieldError
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == fn:
|
||||
if f.__class__.__name__ == 'ForeignKey':
|
||||
fk_names.append(fn)
|
||||
break
|
||||
if fk_names:
|
||||
qs = qs.select_related(*fk_names)
|
||||
page_qs = qs[start:start + page_size]
|
||||
@@ -765,10 +959,10 @@ class FormService:
|
||||
is_fk = field.__class__.__name__ == 'ForeignKey'
|
||||
attr_name = field.attname if is_fk else resolved
|
||||
create_data[attr_name] = val
|
||||
# 强公司隔离:当启用 company_scope 且当前角色有公司上下文时,
|
||||
# 自动注入当前公司 UUID(若模型有 CompanyUUID 字段且未显式提供)
|
||||
# 自动填充当前公司 UUID:若模型有 CompanyUUID 字段且前端未显式提供,
|
||||
# 默认使用当前角色对应的公司(适用于所有用户,包括管理员)。
|
||||
# 系统级表(Menu/Permission/Tenant/Company 等)豁免,不自动注入公司
|
||||
if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
if self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||
if 'CompanyUUID' in field_names:
|
||||
pk_field = model._meta.pk
|
||||
@@ -791,6 +985,22 @@ class FormService:
|
||||
create_data['CompanyUUID_id'] = self.current_company_uuid
|
||||
else:
|
||||
create_data['CompanyUUID'] = self.current_company_uuid
|
||||
# 强租户隔离:当启用 tenant_scope 且当前角色有租户上下文时,
|
||||
# 强制注入当前租户 UUID(覆盖用户传入值,防跨租户注入)
|
||||
# 系统级表(Menu/Permission)豁免;admin:super(tenant_scope=False)不注入,允许跨租户
|
||||
if self.tenant_scope and self.current_tenant_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||
if 'TenantUUID' in field_names:
|
||||
# 判断 TenantUUID 字段类型:ForeignKey 用 _id,UUIDField 用字段名
|
||||
tenant_field = None
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == 'TenantUUID':
|
||||
tenant_field = f
|
||||
break
|
||||
if tenant_field and tenant_field.__class__.__name__ == 'ForeignKey':
|
||||
create_data['TenantUUID_id'] = self.current_tenant_uuid
|
||||
else:
|
||||
create_data['TenantUUID'] = self.current_tenant_uuid
|
||||
pk_field_name = model._meta.pk.name if model._meta.pk else None
|
||||
if pk_field_name and pk_field_name not in create_data:
|
||||
pk_field = model._meta.pk
|
||||
@@ -838,13 +1048,38 @@ class FormService:
|
||||
pk_field_name = model._meta.pk.name
|
||||
pk_field = model._meta.pk
|
||||
pk_value = self._convert_value_for_field(pk_field, pk)
|
||||
# 构造查询条件:附加租户/公司过滤防止跨域越权
|
||||
lookup = {pk_field_name: pk_value}
|
||||
if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||
# 租户隔离:tenant_scope 启用时附加 TenantUUID 过滤(防跨租户改写)
|
||||
if self.tenant_scope and self.current_tenant_uuid and 'TenantUUID' in field_names:
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == 'TenantUUID':
|
||||
if f.__class__.__name__ == 'ForeignKey':
|
||||
lookup['TenantUUID_id'] = self.current_tenant_uuid
|
||||
else:
|
||||
lookup['TenantUUID'] = self.current_tenant_uuid
|
||||
break
|
||||
# 公司隔离:company_scope 启用时附加 CompanyUUID 过滤(防跨公司改写)
|
||||
elif self.company_scope and self.current_company_uuid and 'CompanyUUID' in field_names:
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == 'CompanyUUID':
|
||||
if f.__class__.__name__ == 'ForeignKey':
|
||||
lookup['CompanyUUID_id'] = self.current_company_uuid
|
||||
else:
|
||||
lookup['CompanyUUID'] = self.current_company_uuid
|
||||
break
|
||||
try:
|
||||
obj = model.objects.get(**{pk_field_name: pk_value})
|
||||
obj = model.objects.get(**lookup)
|
||||
except model.DoesNotExist:
|
||||
return None
|
||||
# 强公司隔离:company_scope 启用时校验目标记录归属当前公司(系统级表豁免)
|
||||
if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES and not self._check_company_owner(obj):
|
||||
return None
|
||||
# 归属校验:company_scope 或 tenant_scope 任一启用时校验(系统级表豁免)
|
||||
if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
if self.company_scope and self.current_company_uuid and not self._check_company_owner(obj):
|
||||
return None
|
||||
if self.tenant_scope and self.current_tenant_uuid and not self._check_tenant_owner(obj):
|
||||
return None
|
||||
# 审计日志:修改前捕获旧数据快照
|
||||
old_snapshot = self._get_record_snapshot(obj) if table_name in self.AUDIT_TABLES else None
|
||||
pascal_map = self._build_field_map(model)
|
||||
@@ -857,6 +1092,10 @@ class FormService:
|
||||
continue
|
||||
if resolved == model._meta.pk.name:
|
||||
continue
|
||||
# 保护归属字段:tenant_scope 启用时禁止改写 TenantUUID/CompanyUUID(防数据迁移攻击)
|
||||
# admin:super(tenant_scope=False)不受此限制,可做跨租户数据迁移
|
||||
if self.tenant_scope and resolved in ('TenantUUID', 'CompanyUUID') and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
continue
|
||||
field = pascal_map.get(resolved) or pascal_map.get(key)
|
||||
if field is None:
|
||||
continue
|
||||
@@ -897,13 +1136,38 @@ class FormService:
|
||||
pk_field_name = model._meta.pk.name
|
||||
pk_field = model._meta.pk
|
||||
pk_value = self._convert_value_for_field(pk_field, pk)
|
||||
# 构造查询条件:附加租户/公司过滤防止跨域越权
|
||||
lookup = {pk_field_name: pk_value}
|
||||
if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||
# 租户隔离:tenant_scope 启用时附加 TenantUUID 过滤(防跨租户删除)
|
||||
if self.tenant_scope and self.current_tenant_uuid and 'TenantUUID' in field_names:
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == 'TenantUUID':
|
||||
if f.__class__.__name__ == 'ForeignKey':
|
||||
lookup['TenantUUID_id'] = self.current_tenant_uuid
|
||||
else:
|
||||
lookup['TenantUUID'] = self.current_tenant_uuid
|
||||
break
|
||||
# 公司隔离:company_scope 启用时附加 CompanyUUID 过滤(防跨公司删除)
|
||||
elif self.company_scope and self.current_company_uuid and 'CompanyUUID' in field_names:
|
||||
for f in model._meta.get_fields():
|
||||
if hasattr(f, 'name') and f.name == 'CompanyUUID':
|
||||
if f.__class__.__name__ == 'ForeignKey':
|
||||
lookup['CompanyUUID_id'] = self.current_company_uuid
|
||||
else:
|
||||
lookup['CompanyUUID'] = self.current_company_uuid
|
||||
break
|
||||
try:
|
||||
obj = model.objects.get(**{pk_field_name: pk_value})
|
||||
obj = model.objects.get(**lookup)
|
||||
except model.DoesNotExist:
|
||||
return False
|
||||
# 强公司隔离:company_scope 启用时校验目标记录归属当前公司(系统级表豁免)
|
||||
if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES and not self._check_company_owner(obj):
|
||||
return False
|
||||
# 归属校验:company_scope 或 tenant_scope 任一启用时校验(系统级表豁免)
|
||||
if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
|
||||
if self.company_scope and self.current_company_uuid and not self._check_company_owner(obj):
|
||||
return False
|
||||
if self.tenant_scope and self.current_tenant_uuid and not self._check_tenant_owner(obj):
|
||||
return False
|
||||
# 审计日志:删除前捕获旧数据快照和目标 UUID
|
||||
old_snapshot = self._get_record_snapshot(obj) if table_name in self.AUDIT_TABLES else None
|
||||
_audit_target_uuid = obj.pk if table_name in self.AUDIT_TABLES else None
|
||||
@@ -947,7 +1211,7 @@ class FormService:
|
||||
|
||||
def _check_company_owner(self, obj):
|
||||
"""校验对象是否归属当前公司(用于 company_scope 下的修改/删除保护)。
|
||||
若模型无 CompanyUUID 字段则放行(不隔离)。
|
||||
若模型无 CompanyUUID 字段则放行(依赖 _check_tenant_owner 或外层 lookup 过滤)。
|
||||
CompanyUUID 可能是外键(Department/Workgroup)或主键(Company 本身)。"""
|
||||
try:
|
||||
field_names = {f.name for f in obj._meta.get_fields() if hasattr(f, 'name')}
|
||||
@@ -962,7 +1226,20 @@ class FormService:
|
||||
obj_company = getattr(obj, 'CompanyUUID_id', None) or getattr(obj, 'CompanyUUID', None)
|
||||
return obj_company == self.current_company_uuid
|
||||
except Exception:
|
||||
return True
|
||||
return False # 异常时 fail-closed(拒绝),避免吞异常后越权
|
||||
|
||||
def _check_tenant_owner(self, obj):
|
||||
"""校验对象是否归属当前租户(用于 tenant_scope 下的修改/删除保护)。
|
||||
若模型无 TenantUUID 字段则放行(依赖外层 lookup 过滤或 _check_company_owner)。
|
||||
TenantUUID 可能是外键或 UUIDField。"""
|
||||
try:
|
||||
field_names = {f.name for f in obj._meta.get_fields() if hasattr(f, 'name')}
|
||||
if 'TenantUUID' not in field_names:
|
||||
return True
|
||||
obj_tenant = getattr(obj, 'TenantUUID_id', None) or getattr(obj, 'TenantUUID', None)
|
||||
return obj_tenant == self.current_tenant_uuid
|
||||
except Exception:
|
||||
return False # 异常时 fail-closed(拒绝)
|
||||
|
||||
def _get_record_snapshot(self, obj):
|
||||
"""将模型实例转为可 JSON 序列化的字典(用于审计日志数据快照)"""
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Create WorkgroupRole table: 工作组与角色多对多关联
|
||||
|
||||
工作组只绑定角色,不直接绑定用户。用户通过持有该角色即成为工作组成员。
|
||||
手动创建此迁移以避免 makemigrations 检测到其他模型的预存字段名冲突。
|
||||
"""
|
||||
import datetime
|
||||
import django.db.models.deletion
|
||||
import gvsds.apps.core.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gerp', '0012_doc_editor_menu'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkgroupRole',
|
||||
fields=[
|
||||
('WorkgroupRoleUUID', gvsds.apps.core.models.UUIDField(db_column='WorkgroupRoleUUID', primary_key=True, serialize=False, verbose_name='关联UUID')),
|
||||
('RoleUUID', gvsds.apps.core.models.UUIDField(db_column='RoleUUID', verbose_name='角色UUID')),
|
||||
('RoleType', models.SmallIntegerField(db_column='RoleType', default=0, verbose_name='角色类型')),
|
||||
('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')),
|
||||
('WorkgroupUUID', models.ForeignKey(db_column='WorkgroupUUID', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='gerp.workgroup', verbose_name='工作组UUID')),
|
||||
('CompanyUUID', models.ForeignKey(db_column='CompanyUUID', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='gerp.company', verbose_name='公司UUID')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'WorkgroupRole',
|
||||
'verbose_name': '工作组角色关联',
|
||||
'verbose_name_plural': '工作组角色关联',
|
||||
},
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@@ -311,6 +311,28 @@ class Workgroup(QModel):
|
||||
verbose_name_plural = '工作组'
|
||||
|
||||
|
||||
class WorkgroupRole(QModel):
|
||||
"""工作组角色关联表——工作组与角色的多对多映射
|
||||
|
||||
工作组只绑定角色,不直接绑定用户。用户通过持有该角色即成为工作组成员。
|
||||
RoleType 标识该角色在工作组中的类型:0=组员,1=组长。
|
||||
CompanyUUID 冗余存储用于 SAAS 数据隔离过滤(与 UserRole 表设计一致)。
|
||||
"""
|
||||
|
||||
WorkgroupRoleUUID = UUIDField(primary_key=True, verbose_name='关联UUID')
|
||||
WorkgroupUUID = models.ForeignKey('Workgroup', on_delete=models.CASCADE, related_name='+', db_column='WorkgroupUUID', verbose_name='工作组UUID')
|
||||
RoleUUID = UUIDField(verbose_name='角色UUID')
|
||||
CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='+', db_column='CompanyUUID', verbose_name='公司UUID')
|
||||
RoleType = models.SmallIntegerField(default=0, verbose_name='角色类型')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'WorkgroupRole'
|
||||
app_label = 'gerp'
|
||||
verbose_name = '工作组角色关联'
|
||||
verbose_name_plural = '工作组角色关联'
|
||||
|
||||
|
||||
class ItemReference(QModel):
|
||||
"""物品引用表——物品模板定义"""
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
判断当前用户的角色作用域(管理员/部门负责人/组长/组员),用于数据权限过滤。
|
||||
|
||||
作用域类型:
|
||||
admin —— 管理员(IsSuperuser 或 admin:super 权限),看全公司
|
||||
admin —— 管理员(当前角色拥有 admin:super 或 admin:tenant 权限),看全公司
|
||||
dept_manager —— 部门负责人(UserRole.DeptUUID 非空 + RoleType=1),看本部门成员
|
||||
workgroup_lead —— 工作组长(UserRole.WorkgroupUUID 非空 + RoleType=1),看本工作组成员
|
||||
member —— 普通组员,只看自己
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
from rest_framework.response import Response
|
||||
from gvsdsdk.model_utils import UUIDObj
|
||||
from . import ELTAuthenticatedAPIView
|
||||
from gvsds.apps.gerp.form_service import FormService
|
||||
|
||||
@@ -28,11 +29,20 @@ def _get_current_context(request):
|
||||
|
||||
|
||||
def _is_admin(request):
|
||||
"""判断用户是否为管理员(委托给 gvsdsdk.auth.admin_service.IsAdmin)"""
|
||||
"""判断用户是否为管理员(admin:super 或 admin:tenant,委托给 IsAdmin)"""
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
return IsAdmin(request)
|
||||
|
||||
|
||||
def _is_super_admin(request):
|
||||
"""判断用户是否为跨租户管理员(仅 admin:super,委托给 IsSuperAdmin)
|
||||
|
||||
用于租户隔离判定:仅 admin:super 关闭租户隔离。
|
||||
"""
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
return IsSuperAdmin(request)
|
||||
|
||||
|
||||
def _make_service(request, user):
|
||||
"""构造 FormService,自动传入当前公司 UUID、角色 UUID 和租户 UUID。
|
||||
上下文来自 CurrentRoleMiddleware 注入的 request 属性。
|
||||
@@ -48,9 +58,9 @@ def _make_service(request, user):
|
||||
ip_address = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
ip_address = request.META.get('REMOTE_ADDR', '')
|
||||
# 计算角色作用域(管理员特权模式下不应用作用域过滤)
|
||||
# 计算角色作用域(管理员不应用作用域过滤)
|
||||
role_scope = None
|
||||
if not (admin_verify and _is_admin(request)):
|
||||
if not _is_admin(request):
|
||||
from gvsds.apps.gerp.role_scope import GetUserScope
|
||||
try:
|
||||
role_scope = GetUserScope(request)
|
||||
@@ -80,23 +90,25 @@ _ACTION_PERM_SUFFIX = {
|
||||
def _check_rbac_permission(table_name, action, request):
|
||||
"""RBAC 权限码校验:构造 {Table}:{suffix} 权限码,检查当前角色是否拥有。
|
||||
|
||||
向后兼容:若该权限码在 Permission 表中不存在,返回 None(回退到原有逻辑)。
|
||||
若权限码存在但当前角色未分配,返回 403 错误响应。
|
||||
返回 (error_response, perm_defined):
|
||||
- perm_defined=False: 权限码未定义,调用方应回退到原有逻辑
|
||||
- perm_defined=True, error_response=None: 权限码存在且当前角色已分配 → 放行
|
||||
- perm_defined=True, error_response=Response: 权限码存在但当前角色未分配 → 拒绝
|
||||
"""
|
||||
suffix = _ACTION_PERM_SUFFIX.get(action)
|
||||
if not suffix:
|
||||
return None
|
||||
return None, False
|
||||
perm_code = f'{table_name}:{suffix}'
|
||||
from gvsds.apps.gerp.models import Permission, RolePermission
|
||||
# 检查权限码是否在系统中定义
|
||||
perm_qs = Permission.objects.filter(PermCode=perm_code, PermStatus=1)
|
||||
if not perm_qs.exists():
|
||||
return None # 权限码未定义,回退到原有逻辑
|
||||
return None, False # 权限码未定义,回退到原有逻辑
|
||||
perm_uuids = list(perm_qs.values_list('PermUUID', flat=True))
|
||||
# 检查当前角色是否拥有该权限
|
||||
current_role_uuid, current_company_uuid, _ = _get_current_context(request)
|
||||
if not current_role_uuid:
|
||||
return Response({'code': 403, 'msg': f'无权操作:缺少权限 {perm_code}(未指定当前角色)'}, status=403)
|
||||
return Response({'code': 403, 'msg': f'无权操作:缺少权限 {perm_code}(未指定当前角色)'}, status=403), True
|
||||
rp_qs = RolePermission.objects.filter(
|
||||
RoleUUID=current_role_uuid,
|
||||
PermUUID__in=perm_uuids,
|
||||
@@ -104,8 +116,8 @@ def _check_rbac_permission(table_name, action, request):
|
||||
if current_company_uuid:
|
||||
rp_qs = rp_qs.filter(CompanyUUID=current_company_uuid)
|
||||
if not rp_qs.exists():
|
||||
return Response({'code': 403, 'msg': f'无权操作:当前角色未分配权限 {perm_code}'}, status=403)
|
||||
return None
|
||||
return Response({'code': 403, 'msg': f'无权操作:当前角色未分配权限 {perm_code}'}, status=403), True
|
||||
return None, True
|
||||
|
||||
|
||||
def _check_table_access(table_name, action, request):
|
||||
@@ -113,16 +125,23 @@ def _check_table_access(table_name, action, request):
|
||||
|
||||
校验顺序:
|
||||
1. 管理员直接放行
|
||||
2. RBAC 权限码校验(若权限码已定义则强制校验,未定义则跳过)
|
||||
3. ADMIN_ONLY_TABLES 黑名单
|
||||
4. READONLY_TABLES_FOR_NORMAL 只读约束
|
||||
2. RBAC 权限码校验:
|
||||
- 权限码已定义 → RBAC 校验优先(通过则放行,未通过则拒绝,跳过黑名单/只读约束)
|
||||
- 权限码未定义 → 回退到黑名单/只读约束
|
||||
3. ADMIN_ONLY_TABLES 黑名单(仅权限码未定义时生效)
|
||||
4. READONLY_TABLES_FOR_NORMAL 只读约束(仅权限码未定义时生效)
|
||||
|
||||
设计意图:当管理员为某表配置了 RBAC 权限码并分配给公司负责人等角色时,
|
||||
该角色即可在权限范围内(受公司隔离限制)操作该表,不再受 READONLY 硬编码约束。
|
||||
"""
|
||||
if _is_admin(request):
|
||||
return None
|
||||
# RBAC 权限码校验(向后兼容:权限码未定义时返回 None 不阻断)
|
||||
rbac_error = _check_rbac_permission(table_name, action, request)
|
||||
if rbac_error:
|
||||
# RBAC 权限码校验
|
||||
rbac_error, perm_defined = _check_rbac_permission(table_name, action, request)
|
||||
if perm_defined:
|
||||
# RBAC 驱动模式:权限码已定义,RBAC 校验结果决定是否放行
|
||||
return rbac_error
|
||||
# 回退模式:权限码未定义,使用原有黑名单/只读约束
|
||||
if table_name in FormService.ADMIN_ONLY_TABLES:
|
||||
return Response({'code': 403, 'msg': f'无权访问表 {table_name},仅管理员可操作'}, status=403)
|
||||
if table_name in FormService.READONLY_TABLES_FOR_NORMAL:
|
||||
@@ -331,7 +350,7 @@ class FormAPIView(ELTAuthenticatedAPIView):
|
||||
|
||||
|
||||
class CheckAdminView(ELTAuthenticatedAPIView):
|
||||
"""检查当前用户是否具有管理员特权(超级用户或当前角色拥有 admin:super 权限)
|
||||
"""检查当前用户是否具有管理员特权(超级用户或当前角色拥有 admin:super 或 admin:tenant 权限)
|
||||
|
||||
额外返回 role_scope 信息,用于前端按角色作用域差异化显示:
|
||||
- admin: 管理员(看全公司)
|
||||
@@ -352,6 +371,7 @@ class CheckAdminView(ELTAuthenticatedAPIView):
|
||||
user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None
|
||||
is_superuser = bool(user and getattr(user, 'IsSuperuser', False))
|
||||
is_admin = _is_admin(request)
|
||||
is_super_admin = _is_super_admin(request)
|
||||
current_role_uuid, current_company_uuid, current_tenant_uuid = _get_current_context(request)
|
||||
# 获取角色名
|
||||
role_name = ''
|
||||
@@ -363,9 +383,11 @@ class CheckAdminView(ELTAuthenticatedAPIView):
|
||||
role_name = role.RoleName or ''
|
||||
except Exception:
|
||||
pass
|
||||
# 判断隔离状态(与 _make_service 一致)
|
||||
admin_verify = request.query_params.get('admin_verify') == '1' if hasattr(request, 'query_params') else False
|
||||
company_scope = not (admin_verify and is_admin)
|
||||
# 判断隔离状态(与 FormService.__init__ 一致)
|
||||
# company_scope: admin:super/admin:tenant 关闭,普通用户启用
|
||||
# tenant_scope: 仅 admin:super 关闭,admin:tenant/普通用户启用
|
||||
company_scope = not is_admin
|
||||
tenant_scope = not is_super_admin
|
||||
|
||||
# 计算角色作用域(前端差异化显示依据)
|
||||
scope_type = 'member'
|
||||
@@ -378,9 +400,9 @@ class CheckAdminView(ELTAuthenticatedAPIView):
|
||||
scope_type = scope.Type
|
||||
scope_label = self.SCOPE_TYPE_LABEL.get(scope_type, '普通组员')
|
||||
if scope.DeptUUID:
|
||||
dept_uuid_str = str(_uuid.UUID(bytes=scope.DeptUUID))
|
||||
dept_uuid_str = UUIDObj(scope.DeptUUID).str
|
||||
if scope.WorkgroupUUID:
|
||||
workgroup_uuid_str = str(_uuid.UUID(bytes=scope.WorkgroupUUID))
|
||||
workgroup_uuid_str = UUIDObj(scope.WorkgroupUUID).str
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -389,12 +411,14 @@ class CheckAdminView(ELTAuthenticatedAPIView):
|
||||
'data': {
|
||||
'is_admin': is_admin,
|
||||
'is_superuser': is_superuser,
|
||||
'is_super_admin': is_super_admin,
|
||||
'current_role_name': role_name,
|
||||
'current_role_uuid': str(_uuid.UUID(bytes=current_role_uuid)) if current_role_uuid else None,
|
||||
'current_company_uuid': str(_uuid.UUID(bytes=current_company_uuid)) if current_company_uuid else None,
|
||||
'current_tenant_uuid': str(_uuid.UUID(bytes=current_tenant_uuid)) if current_tenant_uuid else None,
|
||||
'current_role_uuid': UUIDObj(current_role_uuid).str if current_role_uuid else None,
|
||||
'current_company_uuid': UUIDObj(current_company_uuid).str if current_company_uuid else None,
|
||||
'current_tenant_uuid': UUIDObj(current_tenant_uuid).str if current_tenant_uuid else None,
|
||||
'has_current_company': current_company_uuid is not None,
|
||||
'company_scope': company_scope,
|
||||
'tenant_scope': tenant_scope,
|
||||
# 角色作用域(前端差异化显示依据)
|
||||
'role_scope': {
|
||||
'Type': scope_type,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
差异化显示策略(按优先级从高到低):
|
||||
1. 公司过滤:公有菜单(CompanyUUID 为空)+ 当前公司私有菜单(对所有用户生效,包括管理员)
|
||||
2. 祖先链校验:任何祖先不属于当前公司则整个分支移除(对所有用户生效)
|
||||
3. 管理员特权:IsSuperuser 或拥有 admin:super 权限的用户跳过权限过滤
|
||||
3. 管理员特权:当前角色拥有 admin:super 或 admin:tenant 权限的用户跳过权限过滤
|
||||
4. 权限过滤(仅非管理员):按 PermissionAttr 权限码校验,父节点无权限则整个子树隐藏
|
||||
|
||||
管理 API(仅管理员可访问):
|
||||
@@ -59,15 +59,18 @@ class MenuListCompatView(ELTAuthenticatedAPIView):
|
||||
role_uuid_bytes = None
|
||||
|
||||
# 获取当前公司上下文(通过 UserRole.CompanyUUID)
|
||||
# UserRole.CompanyUUID 是 UUIDField(返回 UUIDObj),不能 select_related
|
||||
current_company = None
|
||||
if role_uuid_bytes:
|
||||
try:
|
||||
user_role = UserRole.objects.filter(
|
||||
UserUUID=user.UserUUID,
|
||||
RoleUUID=role_uuid_bytes,
|
||||
).select_related('CompanyUUID').first()
|
||||
).first()
|
||||
if user_role and user_role.CompanyUUID:
|
||||
current_company = user_role.CompanyUUID
|
||||
# 转为 bytes,用于后续 Menu.CompanyUUID(ForeignKey)过滤
|
||||
cc = user_role.CompanyUUID
|
||||
current_company = cc.bytes if hasattr(cc, 'bytes') else cc
|
||||
except Exception:
|
||||
logger.exception('MenuListCompatView 解析当前公司失败')
|
||||
|
||||
@@ -114,7 +117,8 @@ class MenuListCompatView(ELTAuthenticatedAPIView):
|
||||
|
||||
# 祖先链校验:任何祖先不属于当前公司 → 整个分支移除
|
||||
# 对所有用户生效(包括管理员),防止公有子节点挂在私有父节点下导致结构泄漏
|
||||
current_company_uuid_str = str(current_company.CompanyUUID) if current_company else None
|
||||
# current_company 已是 bytes(来自 UserRole.CompanyUUID),转为 UUID 字符串用于匹配
|
||||
current_company_uuid_str = str(_uuid.UUID(bytes=current_company)) if current_company else None
|
||||
invalid_uuids = set()
|
||||
for uuid_str in list(menu_map.keys()):
|
||||
if uuid_str in invalid_uuids:
|
||||
|
||||
@@ -121,9 +121,9 @@ class OrderFlowView(ELTAuthenticatedAPIView):
|
||||
|
||||
# 解析上下文
|
||||
_, current_company_uuid, current_tenant_uuid = _get_current_context(request)
|
||||
admin_verify = request.query_params.get('admin_verify') == '1'
|
||||
# 管理员特权模式下不传 company_uuid(让 OrderManager 跨公司查询)
|
||||
effective_company_uuid = None if (admin_verify and _is_admin(request)) else current_company_uuid
|
||||
# 管理员(admin:super 或 admin:tenant)自动关闭公司隔离,跨公司查询
|
||||
# 注意:OrderManager 按 tenant_uuid 必需过滤,两种管理员都只看本租户订单
|
||||
effective_company_uuid = None if _is_admin(request) else current_company_uuid
|
||||
|
||||
# 先查询订单(用于审计快照)
|
||||
order_uuid_bytes = _to_uuid_bytes(order_uuid)
|
||||
|
||||
@@ -12,7 +12,7 @@ from rest_framework.response import Response
|
||||
|
||||
from . import ELTAuthenticatedAPIView
|
||||
from gvsds.apps.gerp.models import Role, Permission, RolePermission, Company
|
||||
from gvsds.apps.gerp.views.form_api import _is_admin, _get_current_context
|
||||
from gvsds.apps.gerp.views.form_api import _is_admin, _is_super_admin, _get_current_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,7 +62,7 @@ class RolePermissionsTreeView(ELTAuthenticatedAPIView):
|
||||
if not role_uuid:
|
||||
return Response({'code': 1, 'msg': 'RoleUUID 格式无效'}, status=400)
|
||||
|
||||
# 权限校验:仅管理员或当前角色拥有 admin:super 权限可分配
|
||||
# 权限校验:仅管理员(admin:super 或 admin:tenant)可分配
|
||||
if not _is_admin(request):
|
||||
return Response({'code': 403, 'msg': '无权操作:仅管理员可分配角色权限'}, status=403)
|
||||
|
||||
@@ -75,9 +75,9 @@ class RolePermissionsTreeView(ELTAuthenticatedAPIView):
|
||||
if not company_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少 CompanyUUID 参数(无法确定权限分配的公司维度)'}, status=400)
|
||||
|
||||
# 查询权限(按租户过滤;管理员特权模式下可看全部)
|
||||
# 查询权限(按租户过滤;仅跨租户管理员 admin:super 看全部,admin:tenant 保留租户隔离)
|
||||
perm_qs = Permission.objects.filter(PermStatus=1)
|
||||
if tenant_uuid_bytes and not (request.query_params.get('admin_verify') == '1' and _is_admin(request)):
|
||||
if tenant_uuid_bytes and not _is_super_admin(request):
|
||||
perm_qs = perm_qs.filter(TenantUUID=tenant_uuid_bytes)
|
||||
perm_qs = perm_qs.order_by('PermCategory', 'PermName')
|
||||
|
||||
|
||||
@@ -19,21 +19,15 @@ REVIEW_STATUS_PENDING = 0
|
||||
REVIEW_STATUS_APPROVED = 1
|
||||
REVIEW_STATUS_REJECTED = 2
|
||||
|
||||
# 允许访问审核工作台的角色名集合
|
||||
REVIEW_ALLOWED_ROLE_NAMES = {'GERP 测试员'}
|
||||
|
||||
|
||||
def _is_reviewer(request):
|
||||
"""判断用户是否有审核权限:管理员(IsAdmin)或当前角色名在允许集合中"""
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
# 管理员(超级用户/员工/admin:super 权限)直接放行
|
||||
if IsAdmin(request):
|
||||
return True
|
||||
current_role = getattr(request, 'current_role', None)
|
||||
if current_role and hasattr(current_role, 'RoleName'):
|
||||
if current_role.RoleName in REVIEW_ALLOWED_ROLE_NAMES:
|
||||
return True
|
||||
return False
|
||||
"""判断用户是否有审核权限:仅依据当前角色的 admin:super 权限(跨租户管理员)
|
||||
|
||||
租户审核是跨租户的系统级操作,仅 admin:super(如 GERP 测试员)可审核。
|
||||
admin:tenant(租户内管理员)无审核权限。
|
||||
"""
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
return IsSuperAdmin(request)
|
||||
|
||||
|
||||
class TenantApplyView(ELTAuthenticatedAPIView):
|
||||
@@ -102,7 +96,7 @@ class TenantPendingListView(ELTAuthenticatedAPIView):
|
||||
|
||||
|
||||
class TenantApproveView(ELTAuthenticatedAPIView):
|
||||
"""批准租户入驻——仅激活租户(ReviewStatus=1),不自动创建公司/角色"""
|
||||
"""批准租户入驻——激活租户并自动创建"集团超级管理员"角色"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@@ -123,7 +117,16 @@ class TenantApproveView(ELTAuthenticatedAPIView):
|
||||
tenant.ReviewerUUID = getattr(request.user, 'UserUUID', None)
|
||||
tenant.ReviewTime = datetime.datetime.utcnow()
|
||||
tenant.save(update_fields=['ReviewStatus', 'ReviewerUUID', 'ReviewTime'])
|
||||
return Response({'code': 0, 'msg': '审核通过,租户已激活'})
|
||||
# 审核通过后自动创建租户管理员角色
|
||||
role_name = '管理员角色'
|
||||
try:
|
||||
from ..form_service import create_tenant_admin_role
|
||||
role = create_tenant_admin_role(tenant)
|
||||
if role:
|
||||
role_name = getattr(role, 'RoleName', None) or role_name
|
||||
except Exception:
|
||||
logger.exception('审核通过后创建租户管理员角色失败 tenant=%s', tenant.TenantUUID)
|
||||
return Response({'code': 0, 'msg': f'审核通过,租户已激活,已创建{role_name}'})
|
||||
|
||||
|
||||
class TenantRejectView(ELTAuthenticatedAPIView):
|
||||
|
||||
@@ -8,7 +8,7 @@ from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from gvsdsdk.auth.permissions import IsAdminUser, CompanyScopedMixin, TenantScopedMixin
|
||||
from gvsdsdk.auth.permissions import IsAdminUser, CompanyScopedMixin, TenantScopedMixin, UserScopedMixin
|
||||
from gvsdsdk.model_utils import UUIDObj
|
||||
from . import ELTAuthenticatedAPIView
|
||||
from ..models import (
|
||||
@@ -37,7 +37,7 @@ from ..serializers import (
|
||||
)
|
||||
|
||||
|
||||
class TenantViewSet(viewsets.ModelViewSet):
|
||||
class TenantViewSet(TenantScopedMixin, viewsets.ModelViewSet):
|
||||
queryset = Tenant.objects.all()
|
||||
serializer_class = TenantSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -50,7 +50,8 @@ class TenantViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = Tenant.objects.all()
|
||||
# 使用 get_queryset() 自动应用租户隔离(管理员看全部,普通用户只看当前租户)
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
qs = qs.filter(TenantName__icontains=keyword)
|
||||
serializer = self.get_serializer(qs[:20], many=True)
|
||||
@@ -76,7 +77,8 @@ class CompanyViewSet(TenantScopedMixin, viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = Company.objects.all()
|
||||
# 使用 get_queryset() 自动应用租户隔离(管理员看全部,普通用户只看当前租户)
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
from django.db.models import Q
|
||||
qs = qs.filter(
|
||||
@@ -101,7 +103,8 @@ class DepartmentViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = Department.objects.all()
|
||||
# 使用 get_queryset() 自动应用公司隔离(管理员看全部,普通用户只看当前公司)
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
qs = qs.filter(DeptName__icontains=keyword)
|
||||
serializer = self.get_serializer(qs[:20], many=True)
|
||||
@@ -119,6 +122,18 @@ class DepartmentViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
user_bytes = UUIDObj(user_uuid).bytes if isinstance(user_uuid, str) else user_uuid
|
||||
except (ValueError, AttributeError):
|
||||
return Response({'code': 1, 'msg': '无效的UUID'}, status=400)
|
||||
# 归属校验:非 admin:super 校验部门归属当前公司(防跨公司设置负责人)
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
if not IsSuperAdmin(request):
|
||||
current_company = getattr(request, 'current_company_uuid', None)
|
||||
if hasattr(current_company, 'pk'):
|
||||
current_company = current_company.pk
|
||||
elif hasattr(current_company, 'bytes'):
|
||||
current_company = current_company.bytes
|
||||
if current_company:
|
||||
dept = Department.objects.filter(DeptUUID=dept_bytes).first()
|
||||
if dept and getattr(dept, 'CompanyUUID_id', None) != current_company:
|
||||
return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403)
|
||||
smtm, created = DepartmentSMTM.objects.update_or_create(
|
||||
DepartmentUUID=dept_bytes,
|
||||
UserUUID=user_bytes,
|
||||
@@ -140,7 +155,7 @@ class RoleViewSet(TenantScopedMixin, viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = Role.objects.all()
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
qs = qs.filter(RoleName__icontains=keyword)
|
||||
serializer = self.get_serializer(qs[:20], many=True)
|
||||
@@ -154,7 +169,7 @@ class WorkgroupViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = Workgroup.objects.all()
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
qs = qs.filter(WorkgroupName__icontains=keyword)
|
||||
data = []
|
||||
@@ -182,6 +197,19 @@ class UserDeptViewSet(viewsets.ModelViewSet):
|
||||
ser = UserDeptSerializer_Create(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.validated_data
|
||||
# 归属校验:非 admin:super 校验 DeptUUID 归属当前公司(防跨公司/跨租户操作)
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
if not IsSuperAdmin(request):
|
||||
current_company = getattr(request, 'current_company_uuid', None)
|
||||
if hasattr(current_company, 'pk'):
|
||||
current_company = current_company.pk
|
||||
elif hasattr(current_company, 'bytes'):
|
||||
current_company = current_company.bytes
|
||||
if current_company:
|
||||
from gvsds.apps.gerp.models import Department
|
||||
dept = Department.objects.filter(DeptUUID=data['DeptUUID']).first()
|
||||
if dept and getattr(dept, 'CompanyUUID_id', None) != current_company:
|
||||
return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403)
|
||||
existing = UserDept.objects.filter(
|
||||
UserUUID=data['UserUUID'],
|
||||
DeptUUID=data['DeptUUID'],
|
||||
@@ -203,6 +231,19 @@ class UserDeptViewSet(viewsets.ModelViewSet):
|
||||
ser = UserDeptRemoveSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.validated_data
|
||||
# 归属校验:非 admin:super 校验 DeptUUID 归属当前公司(防跨公司/跨租户操作)
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
if not IsSuperAdmin(request):
|
||||
current_company = getattr(request, 'current_company_uuid', None)
|
||||
if hasattr(current_company, 'pk'):
|
||||
current_company = current_company.pk
|
||||
elif hasattr(current_company, 'bytes'):
|
||||
current_company = current_company.bytes
|
||||
if current_company and data.get('DeptUUID'):
|
||||
from gvsds.apps.gerp.models import Department
|
||||
dept = Department.objects.filter(DeptUUID=data['DeptUUID']).first()
|
||||
if dept and getattr(dept, 'CompanyUUID_id', None) != current_company:
|
||||
return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403)
|
||||
deleted, _ = UserDept.objects.filter(
|
||||
UserUUID=data['UserUUID'],
|
||||
DeptUUID=data['DeptUUID'],
|
||||
@@ -222,7 +263,34 @@ class RolePermissionViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
ser = RolePermissionAssignSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.validated_data
|
||||
# CompanyUUID 默认用当前公司上下文(序列化器未要求该字段)
|
||||
company_uuid = data.get('CompanyUUID')
|
||||
if not company_uuid:
|
||||
current_company = getattr(request, 'current_company_uuid', None)
|
||||
if current_company is not None:
|
||||
if hasattr(current_company, 'pk'):
|
||||
company_uuid = current_company.pk
|
||||
elif hasattr(current_company, 'bytes'):
|
||||
company_uuid = current_company.bytes
|
||||
# 归属校验:非 admin:super 校验 RoleUUID/PermUUID 归属当前租户(防跨租户注入权限)
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
if not IsSuperAdmin(request):
|
||||
current_tenant = getattr(request, 'current_tenant_uuid', None)
|
||||
if hasattr(current_tenant, 'bytes'):
|
||||
current_tenant = current_tenant.bytes
|
||||
elif isinstance(current_tenant, str):
|
||||
try:
|
||||
current_tenant = uuid.UUID(current_tenant).bytes
|
||||
except ValueError:
|
||||
current_tenant = None
|
||||
if current_tenant:
|
||||
from gvsds.apps.gerp.models import Role, Permission
|
||||
role = Role.objects.filter(RoleUUID=data['RoleUUID']).first()
|
||||
if role and getattr(role, 'TenantUUID_id', None) != current_tenant and getattr(role, 'TenantUUID', None) != current_tenant:
|
||||
return Response({'code': 403, 'msg': '无权操作:目标角色不属于当前租户'}, status=403)
|
||||
perm = Permission.objects.filter(PermUUID=data['PermUUID']).first()
|
||||
if perm and getattr(perm, 'TenantUUID_id', None) != current_tenant and getattr(perm, 'TenantUUID', None) != current_tenant:
|
||||
return Response({'code': 403, 'msg': '无权操作:目标权限不属于当前租户'}, status=403)
|
||||
existing = RolePermission.objects.filter(
|
||||
RoleUUID=data['RoleUUID'],
|
||||
PermUUID=data['PermUUID'],
|
||||
@@ -265,7 +333,7 @@ class PermissionViewSet(TenantScopedMixin, viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = Permission.objects.all()
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
from django.db.models import Q
|
||||
qs = qs.filter(Q(PermName__icontains=keyword) | Q(PermCode__icontains=keyword))
|
||||
@@ -279,6 +347,21 @@ class PermissionViewSet(TenantScopedMixin, viewsets.ModelViewSet):
|
||||
|
||||
perm_code_raw = serializer.validated_data['PermCode'].strip()
|
||||
tenant_uuid = serializer.validated_data['TenantUUID']
|
||||
# 租户隔离:非 admin:super 强制使用当前租户 UUID(防跨租户创建权限)
|
||||
# admin:super 可跨租户创建;admin:tenant 和普通用户只能创建本租户权限
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
if not IsSuperAdmin(request):
|
||||
current_tenant = getattr(request, 'current_tenant_uuid', None)
|
||||
if current_tenant is not None:
|
||||
if hasattr(current_tenant, 'bytes'):
|
||||
tenant_uuid = current_tenant.bytes
|
||||
elif isinstance(current_tenant, str):
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(current_tenant).bytes
|
||||
except ValueError:
|
||||
pass
|
||||
elif isinstance(current_tenant, bytes):
|
||||
tenant_uuid = current_tenant
|
||||
perm_name = serializer.validated_data.get('PermName', '')
|
||||
perm_category = serializer.validated_data.get('PermCategory', '')
|
||||
perm_status = serializer.validated_data.get('PermStatus', 1)
|
||||
@@ -340,7 +423,7 @@ class FileAssetViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
asset_type = request.query_params.get('type', '')
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
|
||||
qs = FileAsset.objects.all()
|
||||
qs = self.get_queryset()
|
||||
|
||||
if keyword:
|
||||
qs = qs.filter(FileName__icontains=keyword)
|
||||
@@ -551,6 +634,15 @@ class ItemReferenceViewSet(viewsets.ModelViewSet):
|
||||
item = ItemReference.objects.get(ItemUUID=item_bytes)
|
||||
except (ItemReference.DoesNotExist, ValueError):
|
||||
return Response({'code': 1, 'msg': '物品不存在'}, status=404)
|
||||
# IDOR 防护:校验物品归属当前用户(管理员放行)
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
if not IsAdmin(request):
|
||||
_pub = item.ItemPublisherUserUUID
|
||||
_usr = request.user.UserUUID
|
||||
_pub_b = _pub.bytes if hasattr(_pub, 'bytes') else _pub
|
||||
_usr_b = _usr.bytes if hasattr(_usr, 'bytes') else _usr
|
||||
if _pub_b != _usr_b:
|
||||
return Response({'code': 403, 'msg': '无权操作他人物品'}, status=403)
|
||||
for field, val in data.items():
|
||||
setattr(item, field, val)
|
||||
item.save()
|
||||
@@ -564,10 +656,19 @@ class ItemReferenceViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
item_bytes = UUIDObj(item_uuid).bytes
|
||||
item = ItemReference.objects.get(ItemUUID=item_bytes)
|
||||
item.delete()
|
||||
return Response({'code': 0, 'msg': '删除成功'})
|
||||
except (ItemReference.DoesNotExist, ValueError):
|
||||
return Response({'code': 1, 'msg': '物品不存在'}, status=404)
|
||||
# IDOR 防护:校验物品归属当前用户(管理员放行)
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
if not IsAdmin(request):
|
||||
_pub = item.ItemPublisherUserUUID
|
||||
_usr = request.user.UserUUID
|
||||
_pub_b = _pub.bytes if hasattr(_pub, 'bytes') else _pub
|
||||
_usr_b = _usr.bytes if hasattr(_usr, 'bytes') else _usr
|
||||
if _pub_b != _usr_b:
|
||||
return Response({'code': 403, 'msg': '无权操作他人物品'}, status=403)
|
||||
item.delete()
|
||||
return Response({'code': 0, 'msg': '删除成功'})
|
||||
|
||||
|
||||
class BackpackViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
@@ -605,6 +706,15 @@ class BackpackViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
bp = Backpack.objects.get(BackpackItemUUID=bp_bytes)
|
||||
except (Backpack.DoesNotExist, ValueError):
|
||||
return Response({'code': 1, 'msg': '背包物品不存在'}, status=404)
|
||||
# IDOR 防护:校验物品归属当前用户(管理员放行)
|
||||
# 统一转 bytes 比较,避免 UUIDObj 与 bytes 类型不一致导致误判
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
if not IsAdmin(request):
|
||||
_bp_user = bp.UserUUID_id
|
||||
_req_user = request.user.UserUUID
|
||||
_req_user_b = _req_user.bytes if hasattr(_req_user, 'bytes') else _req_user
|
||||
if _bp_user != _req_user_b:
|
||||
return Response({'code': 403, 'msg': '无权操作他人背包物品'}, status=403)
|
||||
for field, val in data.items():
|
||||
setattr(bp, field, val)
|
||||
bp.save()
|
||||
@@ -622,6 +732,15 @@ class BackpackViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
bp = Backpack.objects.get(BackpackItemUUID=bp_bytes)
|
||||
except (Backpack.DoesNotExist, ValueError):
|
||||
return Response({'code': 1, 'msg': '背包物品不存在'}, status=404)
|
||||
# IDOR 防护:校验物品归属当前用户(管理员放行)
|
||||
# 统一转 bytes 比较,避免 UUIDObj 与 bytes 类型不一致导致误判
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
if not IsAdmin(request):
|
||||
_bp_user = bp.UserUUID_id
|
||||
_req_user = request.user.UserUUID
|
||||
_req_user_b = _req_user.bytes if hasattr(_req_user, 'bytes') else _req_user
|
||||
if _bp_user != _req_user_b:
|
||||
return Response({'code': 403, 'msg': '无权操作他人背包物品'}, status=403)
|
||||
if bp.ItemLength <= discard_len:
|
||||
bp.delete()
|
||||
return Response({'code': 0, 'msg': '物品已丢弃'})
|
||||
@@ -654,7 +773,7 @@ class CashReceiptsPaymentsLedgerViewSet(CompanyScopedMixin, viewsets.ModelViewSe
|
||||
ser = FinanceShowSerializer(data=request.query_params or request.data)
|
||||
ser.IsValid(raise_exception=True)
|
||||
data = ser.ValidatedData
|
||||
qs = CashReceiptsPaymentsLedger.objects.all()
|
||||
qs = self.get_queryset()
|
||||
if 'StartDate' in data:
|
||||
qs = qs.filter(CashBookTransactionTime__gte=data['StartDate'])
|
||||
if 'EndDate' in data:
|
||||
@@ -669,16 +788,18 @@ class CashReceiptsPaymentsLedgerViewSet(CompanyScopedMixin, viewsets.ModelViewSe
|
||||
return Response({'code': 0, 'data': serializer.data})
|
||||
|
||||
|
||||
class AssetViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
class AssetViewSet(UserScopedMixin, viewsets.ModelViewSet):
|
||||
queryset = Asset.objects.all()
|
||||
serializer_class = AssetSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
user_field = 'AssetUserUUID'
|
||||
|
||||
|
||||
class LiabilityViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
class LiabilityViewSet(UserScopedMixin, viewsets.ModelViewSet):
|
||||
queryset = Liability.objects.all()
|
||||
serializer_class = LiabilitySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
user_field = 'LiabilityUserUUID'
|
||||
|
||||
|
||||
class PositionTemplateViewSet(TenantScopedMixin, viewsets.ModelViewSet):
|
||||
@@ -689,7 +810,7 @@ class PositionTemplateViewSet(TenantScopedMixin, viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = PositionTemplate.objects.all()
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
qs = qs.filter(TemplateName__icontains=keyword)
|
||||
serializer = self.get_serializer(qs[:20], many=True)
|
||||
@@ -715,7 +836,8 @@ class PositionViewSet(TenantScopedMixin, viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = Position.objects.all()
|
||||
# 使用 get_queryset() 自动应用租户隔离(管理员看全部,普通用户只看当前租户)
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
qs = qs.filter(PositionName__icontains=keyword)
|
||||
data = list(qs[:20].values('PositionUUID', 'PositionName'))
|
||||
@@ -734,9 +856,9 @@ class ShopViewSet(CompanyScopedMixin, viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
from gvsdsdk.models import Shop as ShopModel
|
||||
keyword = request.query_params.get('keyword', '')
|
||||
qs = ShopModel.objects.all()
|
||||
# 使用 get_queryset() 自动应用公司隔离(管理员看全部,普通用户只看当前公司)
|
||||
qs = self.get_queryset()
|
||||
if keyword:
|
||||
qs = qs.filter(ShopName__icontains=keyword)
|
||||
data = list(qs[:20].values('ShopUUID', 'ShopName'))
|
||||
|
||||
Binary file not shown.
@@ -90,11 +90,12 @@ def _ClearJwtCookies(response):
|
||||
def _SetCurrentRoleCookie(response, user):
|
||||
"""查询用户首个角色并设置 CurrentRoleUUID cookie"""
|
||||
from gvsds.apps.gerp.models import UserRole as _UserRole
|
||||
first_role = _UserRole.objects.filter(UserUUID=user.UserUUID).select_related('RoleUUID').first()
|
||||
# UserRole.RoleUUID 是 UUIDField,不能 select_related
|
||||
first_role = _UserRole.objects.filter(UserUUID=user.UserUUID).first()
|
||||
if first_role and first_role.RoleUUID:
|
||||
max_age = int(settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'].total_seconds())
|
||||
response.set_cookie(
|
||||
'CurrentRoleUUID', UUIDObj(first_role.RoleUUID.RoleUUID).str,
|
||||
'CurrentRoleUUID', UUIDObj(first_role.RoleUUID).str,
|
||||
domain=settings.SESSION_COOKIE_DOMAIN, max_age=max_age,
|
||||
secure=True, httponly=False, samesite='None',
|
||||
)
|
||||
|
||||
@@ -8,23 +8,94 @@ SimpleJWT 的 JWTAuthentication 在 __init__ 中调用 django.contrib.auth.get_u
|
||||
|
||||
同时支持从 Authorization 请求头和 HttpOnly Cookie 读取 JWT,
|
||||
以兼容前端基于 Cookie 的认证方式(credentials: 'include')。
|
||||
|
||||
额外职责:JWT 认证成功后立即解析当前角色上下文(current_role / current_company_uuid /
|
||||
current_tenant_uuid),填充到 request 对象。这解决了 Django 中间件在 DRF 认证之前运行、
|
||||
导致中间件无法获取已认证用户的问题。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
from gvsds.apps.core.models import User
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
#: 存储 Access Token 的 Cookie 名称
|
||||
JWT_ACCESS_COOKIE = 'AccessToken'
|
||||
#: 存储 Refresh Token 的 Cookie 名称
|
||||
JWT_REFRESH_COOKIE = 'RefreshToken'
|
||||
|
||||
|
||||
def ResolveRoleContext(request, user):
|
||||
"""解析当前角色上下文,填充 request.current_role / current_company_uuid / current_tenant_uuid
|
||||
|
||||
从 CurrentRoleUUID Cookie 读取角色 UUID,查询 UserRole + Role,
|
||||
设置 request.current_role / current_company_uuid / current_tenant_uuid。
|
||||
|
||||
Args:
|
||||
request: HTTP 请求对象(会被原地修改)
|
||||
user: 已认证的 User 实例
|
||||
"""
|
||||
# 确保默认值(防止重复调用时残留旧值)
|
||||
request.current_role = None
|
||||
request.current_company_uuid = None
|
||||
request.current_tenant_uuid = None
|
||||
|
||||
if not user or not getattr(user, 'is_authenticated', False):
|
||||
if hasattr(user, 'IsAuthenticated'):
|
||||
if not user.IsAuthenticated:
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
from gvsdsdk.models import UserRole, Role
|
||||
from gvsdsdk.model_utils import UUIDObj
|
||||
|
||||
role_uuid_str = request.COOKIES.get('CurrentRoleUUID', '')
|
||||
if not role_uuid_str:
|
||||
_logger.warning('[ResolveRoleContext] 已认证用户 %s 无 CurrentRoleUUID cookie', user)
|
||||
return
|
||||
|
||||
try:
|
||||
role_uuid_bytes = UUIDObj(role_uuid_str).bytes
|
||||
user_role = UserRole.objects.filter(
|
||||
UserUUID=user.UserUUID,
|
||||
RoleUUID=role_uuid_bytes
|
||||
).first()
|
||||
if not user_role:
|
||||
_logger.warning('[ResolveRoleContext] cookie=%s 但 UserRole 未找到 (user=%s)',
|
||||
role_uuid_str[:36], user)
|
||||
return
|
||||
# 单独查询 Role 实例(Role.TenantUUID 提供租户信息)
|
||||
role = Role.objects.filter(RoleUUID=role_uuid_bytes).first()
|
||||
if role:
|
||||
request.current_role = role
|
||||
# 从 Role 实例获取租户 UUID(UUIDObj,FormService 会转 bytes)
|
||||
request.current_tenant_uuid = role.TenantUUID
|
||||
# UserRole.CompanyUUID 是 UUIDField,直接赋值
|
||||
if user_role.CompanyUUID:
|
||||
request.current_company_uuid = user_role.CompanyUUID
|
||||
_logger.info('[ResolveRoleContext] cookie=%s, role=%s, tenant=%s, company=%s',
|
||||
role_uuid_str[:36],
|
||||
getattr(role, 'RoleName', None) if role else None,
|
||||
request.current_tenant_uuid,
|
||||
request.current_company_uuid)
|
||||
except (ValueError, KeyError, ObjectDoesNotExist) as e:
|
||||
_logger.warning('[ResolveRoleContext] cookie=%s 解析失败: %s', role_uuid_str[:36], e)
|
||||
except Exception:
|
||||
_logger.exception('[ResolveRoleContext] 解析当前角色失败')
|
||||
|
||||
|
||||
class CustomJWTAuthentication(JWTAuthentication):
|
||||
"""JWT 认证——使用项目自定义 User 模型(core.User)
|
||||
|
||||
优先从 Authorization 请求头读取 token;
|
||||
若请求头无 token,则回退到 HttpOnly Cookie(AccessToken)。
|
||||
|
||||
认证成功后立即解析当前角色上下文(ResolveRoleContext),
|
||||
确保 request.current_role / current_company_uuid / current_tenant_uuid 可用。
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -47,3 +118,14 @@ class CustomJWTAuthentication(JWTAuthentication):
|
||||
def get_auth_header_types(self):
|
||||
from rest_framework_simplejwt.settings import api_settings
|
||||
return api_settings.AUTH_HEADER_TYPES
|
||||
|
||||
def authenticate(self, request):
|
||||
"""JWT 认证成功后立即解析角色上下文"""
|
||||
result = super().authenticate(request)
|
||||
if result is None:
|
||||
return None
|
||||
user, validated_token = result
|
||||
# 认证成功后立即解析角色上下文(DRF 会在 authenticate 返回后设置 request.user,
|
||||
# 但此时我们已有 user 实例,可直接传入解析)
|
||||
ResolveRoleContext(request, user)
|
||||
return result
|
||||
Binary file not shown.
@@ -4,8 +4,9 @@ __all__ = [
|
||||
'GetDefaultAvatar',
|
||||
'HashPassword', 'VerifyPassword', 'SymmetricEncrypt', 'SymmetricDecrypt', 'FileSHA1', 'FileSHA256', 'BytesSHA1', 'BytesSHA256',
|
||||
'CurrentRoleMiddleware',
|
||||
'IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'PermissionVerifier',
|
||||
'IsAdmin',
|
||||
'IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'TenantScopedMixin', 'UserScopedMixin', 'PermissionVerifier',
|
||||
'IsAdminScopeDisabled', 'IsTenantScopeDisabled',
|
||||
'IsAdmin', 'IsSuperAdmin', 'IsTenantAdmin',
|
||||
'RoleManager', 'ShopManager', 'PositionManager',
|
||||
]
|
||||
|
||||
@@ -24,15 +25,16 @@ def __getattr__(name):
|
||||
from gvsdsdk.auth.middleware import CurrentRoleMiddleware
|
||||
globals()['CurrentRoleMiddleware'] = CurrentRoleMiddleware
|
||||
return CurrentRoleMiddleware
|
||||
if name in ('IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'PermissionVerifier'):
|
||||
if name in ('IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'TenantScopedMixin', 'UserScopedMixin', 'PermissionVerifier', 'IsAdminScopeDisabled', 'IsTenantScopeDisabled'):
|
||||
from gvsdsdk.auth import permissions as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name == 'IsAdmin':
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
globals()['IsAdmin'] = IsAdmin
|
||||
return IsAdmin
|
||||
if name in ('IsAdmin', 'IsSuperAdmin', 'IsTenantAdmin'):
|
||||
from gvsdsdk.auth import admin_service as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name == 'RoleManager':
|
||||
from gvsdsdk.auth.managers import RoleManager
|
||||
globals()['RoleManager'] = RoleManager
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,24 +1,31 @@
|
||||
"""gvsdsdk.auth.admin_service — 管理员身份统一判定服务
|
||||
|
||||
提供单一的 ``IsAdmin`` 函数,作为所有管理员特权判断的统一入口。
|
||||
消除 gerp 各视图中重复实现的 ``_is_admin`` / ``_IsAdminUser`` / ``IsAdmin`` 等函数,
|
||||
统一 ``admin:super`` 权限查询逻辑。
|
||||
提供管理员身份判定的统一入口,基于两个层级的管理员权限码:
|
||||
|
||||
判定顺序:
|
||||
``admin:super`` —— 跨租户管理员(如 GERP 测试员),关闭租户隔离 + 公司隔离
|
||||
``admin:tenant`` —— 租户内管理员(如 集团超级管理员),仅关闭公司隔离,保留租户隔离
|
||||
|
||||
判定顺序(适用于所有判定函数):
|
||||
1. 用户未认证 → False
|
||||
2. IsSuperuser 或 IsStaff → True(系统级管理员)
|
||||
3. 当前角色拥有 ``admin:super`` 权限 → True(租户级管理员)
|
||||
2. 有当前角色 → 仅依据该角色的权限码判定(角色超级管理员模式)
|
||||
超级用户切换到普通角色时也应用隔离,不再因 IsSuperuser/IsStaff 直接放行
|
||||
3. 无当前角色 → IsSuperuser 或 IsStaff 作为系统维护回退(仅授予 admin:tenant 级别,
|
||||
不授予 admin:super,防止超级用户在未切换角色时获得跨租户越权能力)
|
||||
4. 否则 → False
|
||||
|
||||
提供的函数:
|
||||
``IsAdmin`` —— 是否为管理员(admin:super 或 admin:tenant),用于权限校验
|
||||
``IsSuperAdmin`` —— 是否为跨租户管理员(仅 admin:super),用于租户隔离判定
|
||||
``IsTenantAdmin``—— 是否为租户内管理员(仅 admin:tenant),用于租户内管理员判定
|
||||
|
||||
用法:
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
from gvsdsdk.auth.admin_service import IsAdmin, IsSuperAdmin
|
||||
|
||||
if IsAdmin(request):
|
||||
...
|
||||
... # 权限校验放行
|
||||
|
||||
# 或无 request 场景(如 service 层)
|
||||
if IsAdmin(user=user, role_uuid=role_uuid_bytes):
|
||||
...
|
||||
if IsSuperAdmin(request):
|
||||
... # 关闭租户隔离
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,14 +46,62 @@ def _IsAuthenticated(user) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def IsAdmin(request=None, user=None, role_uuid=None) -> bool:
|
||||
"""判断用户是否为管理员(系统级或租户级)
|
||||
def _HasPerm(request=None, user=None, role_uuid=None, perm_code=None) -> bool:
|
||||
"""判断当前角色是否拥有指定权限码
|
||||
|
||||
内部函数,供 IsAdmin / IsSuperAdmin / IsTenantAdmin 复用。
|
||||
判定顺序:
|
||||
1. 用户未认证 → False
|
||||
2. ``IsSuperuser`` 或 ``IsStaff`` → True
|
||||
3. 当前角色拥有 ``admin:super`` 权限 → True
|
||||
2. 有当前角色 → 仅依据该角色的指定权限码判定(角色超级管理员模式)
|
||||
3. 无当前角色 → IsSuperuser 或 IsStaff 作为系统维护回退(仅授予 admin:tenant 级别,
|
||||
perm_code='admin:super' 时直接返回 False,防止跨租户越权)
|
||||
4. 否则 → False
|
||||
"""
|
||||
if perm_code is None:
|
||||
return False
|
||||
|
||||
# 1. 解析 user
|
||||
if request is not None and user is None:
|
||||
user = getattr(request, 'user', None)
|
||||
if not _IsAuthenticated(user):
|
||||
return False
|
||||
|
||||
# 2. 解析 role_uuid(优先从参数,其次从 request.current_role)
|
||||
if role_uuid is None and request is not None:
|
||||
current_role = getattr(request, 'current_role', None)
|
||||
if current_role:
|
||||
role_uuid = getattr(current_role, 'RoleUUID', None)
|
||||
|
||||
# 3. 有当前角色时,仅依据该角色的指定权限码判定(角色超级管理员模式)
|
||||
if role_uuid:
|
||||
try:
|
||||
from gvsdsdk.models import RolePermission, Permission
|
||||
perm_uuids = Permission.objects.filter(
|
||||
PermCode=perm_code,
|
||||
PermStatus=1,
|
||||
).values_list('PermUUID', flat=True)
|
||||
return RolePermission.objects.filter(
|
||||
RoleUUID=role_uuid,
|
||||
PermUUID__in=perm_uuids,
|
||||
).exists()
|
||||
except Exception:
|
||||
_logger.warning('查询权限 %s 失败', perm_code, exc_info=True)
|
||||
return False
|
||||
|
||||
# 4. 无当前角色时,IsSuperuser/IsStaff 作为系统维护回退(无角色无法做权限判定)
|
||||
# 安全策略:无角色时仅授予 admin:tenant 级别权限(不授予 admin:super),
|
||||
# 防止超级用户在未切换角色时获得跨租户越权能力。
|
||||
# superuser 无角色 → IsAdmin=True / IsTenantAdmin=True / IsSuperAdmin=False
|
||||
if perm_code == 'admin:super':
|
||||
return False
|
||||
return bool(getattr(user, 'IsSuperuser', False) or getattr(user, 'IsStaff', False))
|
||||
|
||||
|
||||
def IsAdmin(request=None, user=None, role_uuid=None) -> bool:
|
||||
"""判断用户是否为管理员(admin:super 或 admin:tenant)
|
||||
|
||||
用于权限校验:两种层级的管理员都应放行。
|
||||
跨租户管理员(admin:super)和租户内管理员(admin:tenant)都是管理员。
|
||||
|
||||
参数:
|
||||
request: 请求对象。优先从 ``request.user`` 取用户、``request.current_role`` 取角色。
|
||||
@@ -57,38 +112,22 @@ def IsAdmin(request=None, user=None, role_uuid=None) -> bool:
|
||||
返回:
|
||||
bool
|
||||
"""
|
||||
# 1. 解析 user
|
||||
if request is not None and user is None:
|
||||
user = getattr(request, 'user', None)
|
||||
if not _IsAuthenticated(user):
|
||||
return False
|
||||
|
||||
# 2. 系统级管理员(超级用户/员工)
|
||||
if getattr(user, 'IsSuperuser', False) or getattr(user, 'IsStaff', False):
|
||||
if _HasPerm(request, user, role_uuid, 'admin:super'):
|
||||
return True
|
||||
return _HasPerm(request, user, role_uuid, 'admin:tenant')
|
||||
|
||||
# 3. 解析 role_uuid
|
||||
if role_uuid is None and request is not None:
|
||||
current_role = getattr(request, 'current_role', None)
|
||||
if current_role:
|
||||
role_uuid = getattr(current_role, 'RoleUUID', None)
|
||||
if not role_uuid:
|
||||
return False
|
||||
|
||||
# 4. 租户级管理员(当前角色拥有 admin:super 权限)
|
||||
# 注意:gvsdsdk.models.RolePermission 的 RoleUUID/PermUUID 都是 UUIDField(非 ForeignKey),
|
||||
# 不能用 RoleUUID_id= 或 PermUUID__PermCode 跨表查询。
|
||||
# 改用 subquery + 直接 UUID 匹配。
|
||||
try:
|
||||
from gvsdsdk.models import RolePermission, Permission
|
||||
admin_super_uuids = Permission.objects.filter(
|
||||
PermCode='admin:super',
|
||||
PermStatus=1,
|
||||
).values_list('PermUUID', flat=True)
|
||||
return RolePermission.objects.filter(
|
||||
RoleUUID=role_uuid,
|
||||
PermUUID__in=admin_super_uuids,
|
||||
).exists()
|
||||
except Exception:
|
||||
_logger.warning('IsAdmin 查询 admin:super 权限失败', exc_info=True)
|
||||
return False
|
||||
def IsSuperAdmin(request=None, user=None, role_uuid=None) -> bool:
|
||||
"""判断用户是否为跨租户管理员(仅 admin:super)
|
||||
|
||||
用于隔离控制:仅 admin:super 关闭租户隔离,可跨租户查看数据。
|
||||
"""
|
||||
return _HasPerm(request, user, role_uuid, 'admin:super')
|
||||
|
||||
|
||||
def IsTenantAdmin(request=None, user=None, role_uuid=None) -> bool:
|
||||
"""判断用户是否为租户内管理员(仅 admin:tenant)
|
||||
|
||||
用于租户内管理员判定:仅关闭公司隔离,保留租户隔离。
|
||||
"""
|
||||
return _HasPerm(request, user, role_uuid, 'admin:tenant')
|
||||
@@ -46,9 +46,18 @@ class CurrentRoleMiddleware:
|
||||
# UserRole.CompanyUUID 是 UUIDField,直接赋值 UUID bytes
|
||||
if user_role.CompanyUUID:
|
||||
request.current_company_uuid = user_role.CompanyUUID
|
||||
except (ValueError, KeyError, ObjectDoesNotExist):
|
||||
pass
|
||||
logger.info('[CurrentRoleMiddleware] cookie=%s, role=%s, tenant=%s, company=%s',
|
||||
role_uuid_str[:36],
|
||||
getattr(role, 'RoleName', None) if role else None,
|
||||
request.current_tenant_uuid,
|
||||
request.current_company_uuid)
|
||||
else:
|
||||
logger.warning('[CurrentRoleMiddleware] cookie=%s 但 UserRole 未找到 (user=%s)',
|
||||
role_uuid_str[:36], request.user)
|
||||
except (ValueError, KeyError, ObjectDoesNotExist) as e:
|
||||
logger.warning('[CurrentRoleMiddleware] cookie=%s 解析失败: %s', role_uuid_str[:36], e)
|
||||
except Exception:
|
||||
logger.exception('CurrentRoleMiddleware 解析当前角色失败')
|
||||
|
||||
else:
|
||||
logger.warning('[CurrentRoleMiddleware] 已认证用户 %s 无 CurrentRoleUUID cookie', request.user)
|
||||
return self.get_response(request)
|
||||
+76
-23
@@ -8,31 +8,40 @@ _logger = _logging.getLogger(__name__)
|
||||
|
||||
|
||||
def IsAdminScopeDisabled(request):
|
||||
"""判断是否为管理员特权模式(X-Admin-Verify 头或 admin_verify=1 且用户是管理员),此时关闭隔离
|
||||
"""判断是否为管理员特权模式(当前角色拥有 admin:super 或 admin:tenant 权限),此时关闭公司隔离
|
||||
|
||||
安全:优先从请求头 ``X-Admin-Verify`` 读取(避免 URL 泄漏到访问日志/浏览器历史/Referer),
|
||||
兼容旧版 query 参数 ``admin_verify=1`` 以保证前端渐进迁移。
|
||||
|
||||
管理员判定委托给 ``gvsdsdk.auth.admin_service.IsAdmin``,统一 ``admin:super`` 查询逻辑。
|
||||
与 FormService 行为一致:管理员自动关闭公司隔离,无需前端传 admin_verify=1。
|
||||
管理员判定委托给 ``gvsdsdk.auth.admin_service.IsAdmin``,统一权限查询逻辑。
|
||||
"""
|
||||
if not request:
|
||||
return False
|
||||
# 优先从请求头读取(更安全),兼容 query 参数
|
||||
admin_verify = (
|
||||
request.headers.get('X-Admin-Verify') == '1'
|
||||
or (
|
||||
hasattr(request, 'query_params')
|
||||
and request.query_params.get('admin_verify') == '1'
|
||||
)
|
||||
)
|
||||
if not admin_verify:
|
||||
return False
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
return IsAdmin(request)
|
||||
|
||||
|
||||
def IsTenantScopeDisabled(request):
|
||||
"""判断是否为跨租户管理员特权模式(当前角色拥有 admin:super 权限),此时关闭租户隔离
|
||||
|
||||
仅 admin:super(跨租户管理员)可关闭租户隔离,admin:tenant(租户内管理员)保留租户隔离。
|
||||
管理员判定委托给 ``gvsdsdk.auth.admin_service.IsSuperAdmin``。
|
||||
"""
|
||||
if not request:
|
||||
return False
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin
|
||||
return IsSuperAdmin(request)
|
||||
|
||||
|
||||
class IsAdminUser(BasePermission):
|
||||
"""仅允许管理员(IsStaff 或 IsSuperuser)访问"""
|
||||
"""仅允许管理员(当前角色拥有 admin:super 或 admin:tenant 权限)访问
|
||||
|
||||
两级管理员体系:
|
||||
- admin:super:跨租户管理员(GERP 测试员),关闭租户隔离 + 公司隔离
|
||||
- admin:tenant:租户内管理员(集团超级管理员),仅关闭公司隔离,保留租户隔离
|
||||
|
||||
依据当前角色的权限判定,超级用户切换到普通角色时不再自动放行,
|
||||
需切换到拥有 admin:super 或 admin:tenant 权限的角色。
|
||||
无当前角色时回退到 IsSuperuser/IsStaff(系统维护场景)。
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
@@ -40,7 +49,8 @@ class IsAdminUser(BasePermission):
|
||||
return False
|
||||
if not isinstance(user, _GetUserModel()):
|
||||
return False
|
||||
return user.IsStaff or user.IsSuperuser
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
return IsAdmin(request)
|
||||
|
||||
|
||||
class IsCompanyMember(BasePermission):
|
||||
@@ -99,13 +109,17 @@ class TenantScopedMixin:
|
||||
"""ViewSet Mixin:自动按当前角色所属租户过滤 queryset
|
||||
|
||||
用于纯租户级表(如 Tenant/Role/CompanyInfo),这些表无 CompanyUUID 字段,
|
||||
只能按 TenantUUID 过滤。当 URL 带 admin_verify=1 且用户为管理员时关闭隔离。
|
||||
只能按 TenantUUID 过滤。仅跨租户管理员(admin:super)关闭租户隔离,
|
||||
租户内管理员(admin:tenant)保留租户隔离。
|
||||
"""
|
||||
tenant_field = 'TenantUUID' # 可在 ViewSet 中覆盖
|
||||
|
||||
def _is_admin_scope_disabled(self):
|
||||
"""判断是否为管理员特权模式(委托给模块级 IsAdminScopeDisabled)"""
|
||||
return IsAdminScopeDisabled(getattr(self, 'request', None))
|
||||
"""判断是否为跨租户管理员特权模式(委托给模块级 IsTenantScopeDisabled)
|
||||
|
||||
仅 admin:super 关闭租户隔离,admin:tenant 保留租户隔离。
|
||||
"""
|
||||
return IsTenantScopeDisabled(getattr(self, 'request', None))
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
@@ -124,6 +138,43 @@ class TenantScopedMixin:
|
||||
serializer.save()
|
||||
|
||||
|
||||
class UserScopedMixin:
|
||||
"""ViewSet Mixin:自动按当前用户过滤 queryset(用户级数据隔离)
|
||||
|
||||
用于无 CompanyUUID/TenantUUID 字段的用户级表(如 Backpack/Message/WorkOrder/Asset/Liability),
|
||||
普通用户只能看自己的数据,管理员(admin:super 或 admin:tenant)看全部。
|
||||
|
||||
使用方式:
|
||||
class BackpackViewSet(UserScopedMixin, viewsets.ModelViewSet):
|
||||
user_field = 'UserUUID' # FK 字段名(自动追加 _id)或 UUIDField 名
|
||||
"""
|
||||
user_field = 'UserUUID' # 可在 ViewSet 中覆盖
|
||||
|
||||
def _is_admin_scope_disabled(self):
|
||||
"""判断是否为管理员特权模式(委托给模块级 IsAdminScopeDisabled)"""
|
||||
return IsAdminScopeDisabled(getattr(self, 'request', None))
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if self._is_admin_scope_disabled():
|
||||
return qs
|
||||
user = getattr(self.request, 'user', None) if hasattr(self, 'request') else None
|
||||
if not user or not getattr(user, 'is_authenticated', False):
|
||||
return qs.none()
|
||||
user_uuid = getattr(user, 'UserUUID', None)
|
||||
if user_uuid is None:
|
||||
return qs.none()
|
||||
# 统一转 bytes(UUIDObj → bytes)
|
||||
user_uuid = user_uuid.bytes if hasattr(user_uuid, 'bytes') else user_uuid
|
||||
# 判断字段类型:ForeignKey 用 _id 后缀,UUIDField 直接用字段名
|
||||
fk_attr = self.user_field + '_id'
|
||||
if hasattr(qs.model, fk_attr):
|
||||
qs = qs.filter(**{fk_attr: user_uuid})
|
||||
elif hasattr(qs.model, self.user_field):
|
||||
qs = qs.filter(**{self.user_field: user_uuid})
|
||||
return qs
|
||||
|
||||
|
||||
_PERM_CACHE_KEY = '_gvsdsdk_perm_verifier_cache'
|
||||
_ROLE_INACTIVE = object() # 哨兵:角色未启用,恒拒绝
|
||||
|
||||
@@ -133,7 +184,7 @@ def PermissionVerifier(permission, attributes, user=None, request=None):
|
||||
|
||||
RBAC 仅检查当前角色(CurrentRoleUUID Cookie 指定的角色)的权限,
|
||||
通过 RolePermission → Permission 链路查询,按当前公司过滤。
|
||||
超级管理员/员工直接放行。
|
||||
管理员(当前角色拥有 admin:super 权限)直接放行。
|
||||
|
||||
性能优化:ABAC 权限标识集合与 RBAC 权限码集合在首次调用时预取,
|
||||
并挂载到 request 对象上作为请求级缓存,后续同一请求内的多次调用
|
||||
@@ -146,8 +197,10 @@ def PermissionVerifier(permission, attributes, user=None, request=None):
|
||||
if not isinstance(user, _GetUserModel()):
|
||||
return False
|
||||
|
||||
# 超级管理员和员工直接放行
|
||||
if user.IsSuperuser or user.IsStaff:
|
||||
# 管理员(当前角色拥有 admin:super 权限)直接放行
|
||||
# 采用角色超级管理员模式:超级用户切换到普通角色时也需通过 RBAC 校验
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
if IsAdmin(request=request, user=user):
|
||||
return True
|
||||
|
||||
current_role = getattr(request, 'current_role', None) if request else None
|
||||
|
||||
@@ -63,6 +63,10 @@ class UUIDObj:
|
||||
def __bool__(self):
|
||||
return self._raw is not None
|
||||
|
||||
def __len__(self):
|
||||
# UUID 固定 16 字节;空值返回 0
|
||||
return 16 if self._raw is not None else 0
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _GetUserModel():
|
||||
|
||||
Reference in new issue
Block a user