docs(requirements): 需求裁决对照表 v2、功能模块清单与 M1 设计产物入库(移除被 v2 取代的旧清单)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-13 09:26:21 +08:00
1 parent 8668e58c30
commit e568ebde61
9 files changed
+1896

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,(function(t){"use strict";
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:"undefined"==typeof navigator||0===navigator.userAgent.indexOf("Node.js")?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11),e.domSupported="undefined"!=typeof document;var s=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in s||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in s)&&!("OTransition"in s),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n<t.length;n++){var i=String.fromCharCode(n+32),r=(t.charCodeAt(n)-20)/100;e[i]=r}return e}("007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N"),h={createCanvas:function(){return"undefined"!=typeof document&&document.createElement("canvas")},measureText:function(t,e){if(!s){var n=h.createCanvas();s=n&&n.getContext("2d")}if(s)return l!==e&&(l=s.font=e||a),s.measureText(t);t=t||"";var i=/((?:\d+)?\.?\d*)px/.exec(e=e||a),r=i&&+i[1]||12,o=0;if(e.indexOf("mono")>=0)o=r*t.length;else for(var c=0;c<t.length;c++){var p=u[t[c]];o+=null==p?r:p*r}return{width:o}},loadImage:function(t,e,n){var i=new Image;return i.onload=e,i.onerror=n,i.src=t,i}};function c(t){for(var e in h)t[e]&&(h[e]=t[e])}var p=V(["Function","RegExp","Date","Error","CanvasGradient","CanvasPattern","Image","Canvas"],(function(t,e){return t["[object "+e+"]"]=!0,t}),{}),d=V(["Int8","Uint8","Uint8Clamped","Int16","Uint16","Int32","Uint32","Float32","Float64"],(function(t,e){return t["[object "+e+"Array]"]=!0,t}),{}),f=Object.prototype.toString,g=Array.prototype,y=g.forEach,v=g.filter,m=g.slice,x=g.map,_=function(){}.constructor,b=_?_.prototype:null,w="__proto__",S=2311;function M(){return S++}function I(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];"undefined"!=typeof console&&console.error.apply(console,t)}function T(t){if(null==t||"object"!=typeof t)return t;var e=t,n=f.call(t);if("[object Array]"===n){if(!pt(t)){e=[];for(var i=0,r=t.length;i<r;i++)e[i]=T(t[i])}}else if(d[n]){if(!pt(t)){var o=t.constructor;if(o.from)e=o.from(t);else{e=new o(t.length);for(i=0,r=t.length;i<r;i++)e[i]=t[i]}}}else if(!p[n]&&!pt(t)&&!J(t))for(var a in e={},t)t.hasOwnProperty(a)&&a!==w&&(e[a]=T(t[a]));return e}function C(t,e,n){if(!q(e)||!q(t))return n?T(e):t;for(var i in e)if(e.hasOwnProperty(i)&&i!==w){var r=t[i],o=e[i];!q(o)||!q(r)||Y(o)||Y(r)||J(o)||J(r)||K(o)||K(r)||pt(o)||pt(r)?!n&&i in t||(t[i]=T(e[i])):C(r,o,n)}return t}function D(t,e){for(var n=t[0],i=1,r=t.length;i<r;i++)n=C(n,t[i],e);return n}function A(t,e){if(Object.assign)Object.assign(t,e);else for(var n in e)e.hasOwnProperty(n)&&n!==w&&(t[n]=e[n]);return t}function k(t,e,n){for(var i=G(e),r=0;r<i.length;r++){var o=i[r];(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}return t}var L=h.createCanvas;function P(t,e){if(t){if(t.indexOf)return t.indexOf(e);for(var n=0,i=t.length;n<i;n++)if(t[n]===e)return n}return-1}function O(t,e){var n=t.prototype;function i(){}for(var r in i.prototype=e.prototype,t.prototype=new i,n)n.hasOwnProperty(r)&&(t.prototype[r]=n[r]);t.prototype.constructor=t,t.superClass=e}function R(t,e,n){if(t="prototype"in t?t.prototype:t,e="prototype"in e?e.prototype:e,Object.getOwnPropertyNames)for(var i=Object.getOwnPropertyNames(e),r=0;r<i.length;r++){var o=i[r];"constructor"!==o&&(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}else k(t,e,n)}function N(t){retuLine truncated
/*!
* ZRender, a high performance 2d drawing library.
*
* Copyright (c) 2013, Baidu Inc.
* All rights reserved.
*
* LICENSE
* https://github.com/ecomfe/zrender/blob/master/LICENSE.txt
*/
var Fr={},Gr={};var Wr,Hr=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var o=new rn,a=n.renderer||"canvas";Fr[a]||(a=G(Fr)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var s=new Fr[a](e,o,n,t),l=n.ssr||s.ssrOnly;this.storage=o,this.painter=s;var u,h=r.node||r.worker||l?null:new or(s.getViewportRoot(),s.root),c=n.useCoarsePointer;(null==c||"auto"===c?r.touchEventsSupported:!!c)&&(u=rt(n.pointerSize,44)),this.handler=new Ye(o,s,h,s.root,u),this.animation=new Wi({stage:{update:l?null:function(){return i._flush(!0)}}}),l||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return oi(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r<i;r++)n+=oi(e[r].color,1);return(n/=i)<.4}return!1}(t))},t.prototype.getBackgroundColor=function(){return this._backgroundColor},t.prototype.setDarkMode=function(t){this._darkMode=t},t.prototype.isDarkMode=function(){return this._darkMode},t.prototype.refreshImmediately=function(t){this._disposed||(t||this.animation.update(!0),this._needsRefresh=!1,this.painter.refresh(),this._needsRefresh=!1)},t.prototype.refresh=function(){this._disposed||(this._needsRefresh=!0,this.animation.start())},t.prototype.flush=function(){this._disposed||this._flush(!1)},t.prototype._flush=function(t){var e,n=Bi();this._needsRefresh&&(e=!0,this.refreshImmediately(t)),this._needsRefreshHover&&(e=!0,this.refreshHoverImmediately());var i=Bi();e?(this._stillFrameAccum=0,this.trigger("rendered",{elapsedTime:i-n})):this._sleepAfterStill>0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e<t.length;e++)t[e]instanceof Br&&t[e].removeSelfFromZr(this);this.storage.delAllRoots(),this.painter.clear()}},t.prototype.dispose=function(){var t;this._disposed||(this.animation.stop(),this.clear(),this.storage.dispose(),this.painter.dispose(),this.handler.dispose(),this.animation=this.storage=this.painter=this.handler=null,this._disposed=!0,t=this.id,delete Gr[t])},t}();function Yr(t,e){var n=new Hr(M(),t,e);return Gr[n.id]=n,n}function Xr(t,e){Fr[t]=e}function Ur(t){if("function"==typeof Wr)return Wr(t)}function Zr(t){Wr=t}var jr=Object.freeze({__proto__:null,init:Yr,dispose:function(t){t.dispose()},disposeAll:function(){for(var t in Gr)Gr.hasOwnProperty(t)&&Gr[t].dispose();Gr={}},getInstance:function(t){return Gr[t]},registerPainter:Xr,getElementSSRData:Ur,registerSSRDataGetter:Zr,version:"5.6.0"}),qr=1e-4;function Kr(t,e,n,i){var r=e[0],o=e[1],a=n[0],s=n[1],l=o-r,u=s-a;if(0===l)return 0===u?a:(a+s)/2;if(i)if(l>0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}function $r(t,e){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return U(t)?(n=t,n.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e:parseFloat(t):null==t?NaN:+t;var n}function Jr(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Qr(t){return t.sort((funLine truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+976
View File
@@ -0,0 +1,976 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>衍水工资系统 · 视觉方向预览</title>
<style>
/* ============================================================
设计体系 v3(质感版):中性灰白 · 靛蓝点缀 · 微质感
质感来源(而非装饰):
1) 表面微渐变 —— 1–2% 明度差,暗示受光方向
2) 半透明描边 —— 边界在不同底色上自然融合
3) 分层阴影 —— 近距实影 + 远距弥散影叠加
4) 内高光 / 内阴影 —— 浮起与凹陷的材质暗示
5) 极微噪点 —— 去除纯色的「塑料感」
6) 精确排版 —— 字距、等宽数字、行高的一致节律
============================================================ */
:root {
--ys-indigo: #2e4ba8;
--ys-indigo-hi: #3a58bc; /* 渐变亮端 */
--ys-indigo-deep: #243c8b;
--ys-indigo-wash: #edf1fa;
--ys-paper: #ffffff;
--ys-mist: #f4f5f8; /* 页面底 */
--ys-aside-1: #f2f4f8; /* 侧栏渐变起 */
--ys-aside-2: #ecedf3; /* 侧栏渐变止 */
--ys-ink: #1c2333;
--ys-ink-2: #5b6577;
--ys-ink-3: #9aa3b2;
--ys-line: rgba(28, 35, 51, 0.09); /* 半透明描边 */
--ys-line-soft: rgba(28, 35, 51, 0.065);
--ys-ok: #1e9e6a; --ys-ok-wash-1: #ecf8f2; --ys-ok-wash-2: #e4f3ec;
--ys-warn: #c98a1b; --ys-warn-wash-1: #fbf4e4; --ys-warn-wash-2: #f8efd9;
--ys-danger: #c94f4f; --ys-danger-wash: #fbeded;
--ys-radius: 10px;
--ys-radius-sm: 7px;
--ys-font: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', -apple-system,
'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
/* 分层阴影:近距定义轮廓,远距弥散氛围 */
--ys-shadow-card: 0 1px 2px rgba(28, 35, 51, 0.04), 0 3px 10px rgba(28, 35, 51, 0.03);
--ys-shadow-raise: 0 1px 2px rgba(28, 35, 51, 0.05), 0 6px 18px rgba(28, 35, 51, 0.07);
/* 极微噪点:去除大色块的塑料感(内联 SVG,无外部依赖) */
--ys-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
/* 品牌字:思源黑体(现代几何黑体;实施时自托管 4 字子集,内网可用) */
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--ys-font);
color: var(--ys-ink);
background-color: var(--ys-mist);
/* 点阵网格:22px 间距的 1px 微点 —— 给背景「秩序与密度」,不再空旷 */
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
background-size: 24px 24px;
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* 噪点覆盖层:全页 2.2% 强度,肉眼几乎无感,但色块不再是「纯色塑料」 */
body::after {
content: ''; position: fixed; inset: 0; z-index: 2147483647;
pointer-events: none; opacity: 0.022; background-image: var(--ys-noise);
}
.num { font-variant-numeric: tabular-nums; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 48px 32px 96px; }
.section { margin-top: 64px; }
.section > h2 {
font-size: 13px; font-weight: 500; letter-spacing: 0.08em;
color: var(--ys-ink-3); text-transform: uppercase;
display: flex; align-items: center; gap: 10px; margin-bottom: 20px;
}
.section > h2::after { content: ''; height: 1px; flex: 1; background: var(--ys-line); }
/* 签名元素:水线 */
.waterline {
width: 56px; height: 2px; border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
margin: 10px 0 0;
}
/* ---------- 封面 ---------- */
.cover { padding: 24px 0 8px; }
.cover .brand {
font-family: var(--ys-font-brand);
font-size: 15px; font-weight: 500; letter-spacing: 0.32em; color: var(--ys-ink-2);
}
.cover h1 { font-size: 30px; font-weight: 600; margin-top: 18px; letter-spacing: -0.01em; }
.cover .sub { color: var(--ys-ink-2); margin-top: 10px; font-size: 15px; }
.cover .tags { display: flex; gap: 8px; margin-top: 22px; flex-wrap: wrap; }
.tag {
font-size: 12px; color: var(--ys-ink-2);
background: linear-gradient(180deg, #ffffff, #fbfcfe);
border: 1px solid var(--ys-line); border-radius: 999px; padding: 4px 12px;
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
}
/* ---------- 色板 ---------- */
.swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.swatch {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
overflow: hidden; box-shadow: var(--ys-shadow-card);
}
.swatch .chip { height: 76px; }
.swatch .meta { padding: 10px 14px 12px; }
.swatch .name { font-size: 13px; font-weight: 600; }
.swatch .hex { font-size: 12px; color: var(--ys-ink-2); margin-top: 2px; }
.swatch .use { font-size: 12px; color: var(--ys-ink-3); margin-top: 6px; line-height: 1.5; }
/* ---------- 字体 ---------- */
.type-sheet {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
padding: 26px 28px; box-shadow: var(--ys-shadow-card);
}
.type-row { display: flex; align-items: baseline; gap: 24px; padding: 12px 0; border-bottom: 1px dashed var(--ys-line-soft); }
.type-row:last-child { border-bottom: 0; }
.type-row .label { width: 150px; flex: none; font-size: 12px; color: var(--ys-ink-3); }
.t-page-title { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
.t-card-title { font-size: 15px; font-weight: 600; }
.t-body { font-size: 14px; }
.t-th { font-size: 12px; font-weight: 500; letter-spacing: 0.04em; color: var(--ys-ink-2); }
.t-amount { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.amount-demo {
margin-top: 18px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
padding: 18px 28px; box-shadow: var(--ys-shadow-card);
}
.amount-demo table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.amount-demo th {
text-align: right; font-size: 12px; font-weight: 500; letter-spacing: 0.04em;
color: var(--ys-ink-2); padding: 6px 0; border-bottom: 1px solid var(--ys-line);
}
.amount-demo th:first-child { text-align: left; }
.amount-demo td { text-align: right; padding: 9px 0; border-bottom: 1px solid var(--ys-line-soft); font-size: 13px; }
.amount-demo td:first-child { text-align: left; color: var(--ys-ink-2); }
.amount-demo tr:last-child td { border-bottom: 0; font-weight: 600; font-size: 15px; }
/* 品牌字候选(思源黑体已装于本机;实施时自托管子集) */
.brand-candidates { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 14px; }
.bc {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card); padding: 20px 24px; text-align: center;
}
.bc-cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 12px; }
.bc-rec { color: var(--ys-indigo); font-weight: 500; margin-left: 4px; }
/* ---------- 组件 ---------- */
.comp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.comp-card {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
padding: 22px 24px; box-shadow: var(--ys-shadow-card);
}
.comp-card h3 { font-size: 13px; font-weight: 600; color: var(--ys-ink-2); margin-bottom: 16px; letter-spacing: 0.02em; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.btn {
height: 34px; padding: 0 16px; border-radius: var(--ys-radius-sm);
font-size: 13px; font-family: inherit; cursor: pointer;
border: 1px solid transparent;
transition: all 150ms var(--ys-ease);
}
/* 主按钮:微渐变 + 内高光 + 静置浅影 —— hover 抬升 1px、影加深 */
.btn-primary {
color: #fff;
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 1px 2px rgba(46, 75, 168, 0.22);
}
.btn-primary:hover {
background: linear-gradient(180deg, #31509f, var(--ys-indigo-deep));
transform: translateY(-1px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 4px 12px rgba(46, 75, 168, 0.3);
}
.btn-primary:active { transform: translateY(0); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); }
.btn-default {
background: linear-gradient(180deg, #ffffff, #fbfcfe); color: var(--ys-ink);
border-color: var(--ys-line); box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
}
.btn-default:hover { color: var(--ys-indigo); border-color: rgba(46, 75, 168, 0.45); }
.btn-danger { background: linear-gradient(180deg, #ffffff, #fbfcfe); color: var(--ys-danger); border-color: rgba(201, 79, 79, 0.28); }
.btn-danger:hover { border-color: rgba(201, 79, 79, 0.6); }
.btn-text { background: none; color: var(--ys-indigo); padding: 0 6px; height: auto; }
.btn-text:hover { color: var(--ys-indigo-deep); }
.btn-ghost-hover { pointer-events: none; } /* 预览:静态展示 hover 态 */
.input {
height: 34px; padding: 0 12px; border-radius: var(--ys-radius-sm);
border: 1px solid rgba(28, 35, 51, 0.13); background: #fff;
font-size: 13px; font-family: inherit; color: var(--ys-ink); width: 190px;
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.035); /* 微凹陷:输入区的材质暗示 */
transition: border-color 150ms var(--ys-ease), box-shadow 150ms var(--ys-ease);
}
.input::placeholder { color: var(--ys-ink-3); }
.input:focus, .input.focused {
outline: none; border-color: var(--ys-indigo);
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.03), 0 0 0 3px rgba(46, 75, 168, 0.13);
}
.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 10px; border-radius: 999px; white-space: nowrap; }
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill-ok { color: var(--ys-ok); background: linear-gradient(180deg, var(--ys-ok-wash-1), var(--ys-ok-wash-2)); }
.pill-off { color: var(--ys-ink-2); background: linear-gradient(180deg, #f4f5f8, #eff1f5); }
.pill-warn { color: var(--ys-warn); background: linear-gradient(180deg, var(--ys-warn-wash-1), var(--ys-warn-wash-2)); }
.pager { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--ys-ink-2); }
.pager .pg {
min-width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--ys-radius-sm); border: 1px solid var(--ys-line);
background: linear-gradient(180deg, #ffffff, #fbfcfe); font-variant-numeric: tabular-nums;
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
}
.pager .pg.cur {
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
border-color: transparent; color: #fff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 1px 2px rgba(46, 75, 168, 0.22);
}
/* ---------- 应用界面 ---------- */
.app-shot {
border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden;
background: var(--ys-mist); box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1);
}
.app { display: grid; grid-template-columns: 220px 1fr; min-height: 620px; }
.aside {
background: linear-gradient(180deg, var(--ys-aside-1), var(--ys-aside-2));
border-right: 1px solid var(--ys-line);
padding: 20px 14px; display: flex; flex-direction: column;
}
.aside .logo { font-family: var(--ys-font-brand); font-size: 15px; font-weight: 500; letter-spacing: 0.3em; color: var(--ys-ink); padding: 0 10px; }
.aside .logo-sub { font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.06em; padding: 0 10px; margin-top: 4px; }
.menu-group { margin-top: 26px; }
.menu-label { font-size: 11px; letter-spacing: 0.1em; color: var(--ys-ink-3); padding: 0 10px; margin-bottom: 8px; }
.menu-item {
display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: var(--ys-radius-sm);
font-size: 13px; color: var(--ys-ink-2); position: relative; margin-bottom: 2px;
transition: all 150ms var(--ys-ease);
}
.menu-item:hover { color: var(--ys-ink); background: rgba(255, 255, 255, 0.55); }
/* 当前项:白底「浮起」——微渐变 + 内高光 + 分层阴影 */
.menu-item.active {
color: var(--ys-indigo); font-weight: 500;
background: linear-gradient(180deg, #ffffff, #fbfcfe);
box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(28, 35, 51, 0.06), 0 4px 10px rgba(28, 35, 51, 0.06);
}
.menu-item.active::before {
content: ''; position: absolute; left: -14px; top: 8px; bottom: 8px; width: 2px;
border-radius: 1px; background: var(--ys-indigo);
}
.menu-item svg { width: 16px; height: 16px; flex: none; opacity: 0.7; }
.menu-item.active svg { opacity: 1; }
.aside-foot { margin-top: auto; padding: 0 10px 2px; }
.aside-foot-text { font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.06em; }
.main { display: flex; flex-direction: column; }
.topbar {
height: 52px;
background: linear-gradient(180deg, #ffffff, #fdfdfe);
border-bottom: 1px solid var(--ys-line);
box-shadow: 0 1px 0 rgba(28, 35, 51, 0.015);
display: flex; align-items: center; justify-content: space-between; padding: 0 22px;
}
.crumb { font-size: 13px; color: var(--ys-ink-3); }
.crumb b { color: var(--ys-ink); font-weight: 500; }
.topbar .who { display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--ys-ink-2); }
.topbar .who .logout { color: var(--ys-ink-3); }
.topbar .who .avatar {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(180deg, var(--ys-indigo-wash), #e4eaf7);
box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(28, 35, 51, 0.06);
color: var(--ys-indigo); font-size: 12px;
display: inline-flex; align-items: center; justify-content: center;
}
.content {
padding: 22px; flex: 1;
/* 顶部受光:白光 + 靛蓝微光叠加,内容区有「空气感」 */
background:
radial-gradient(1100px 320px at 50% -110px, rgba(46, 75, 168, 0.055), rgba(46, 75, 168, 0) 65%),
radial-gradient(900px 220px at 50% -80px, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0) 70%);
}
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; }
.page-head h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
.page-head .desc { font-size: 12px; color: var(--ys-ink-3); margin-top: 4px; }
.filter {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
padding: 14px 16px; display: flex; gap: 10px; align-items: center; margin-bottom: 14px;
box-shadow: var(--ys-shadow-card);
}
.filter .spacer { flex: 1; }
.card {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card); overflow: hidden;
}
table.grid { width: 100%; border-collapse: collapse; }
table.grid th {
text-align: left; font-size: 12px; font-weight: 500; letter-spacing: 0.04em;
color: var(--ys-ink-2); background: linear-gradient(180deg, #fafbfc, #f5f6f9);
padding: 10px 16px; border-bottom: 1px solid var(--ys-line);
}
table.grid td { padding: 12px 16px; border-bottom: 1px solid var(--ys-line-soft); font-size: 13px; }
table.grid tr:last-child td { border-bottom: 0; }
table.grid tbody tr { transition: background 120ms var(--ys-ease); }
table.grid tbody tr:hover { background: #f7f9fd; }
.cell-code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 12px; color: var(--ys-ink-2); }
.cell-muted { color: var(--ys-ink-2); }
.cell-actions a { color: var(--ys-indigo); font-size: 13px; margin-right: 12px; cursor: pointer; }
.cell-actions a.muted { color: var(--ys-ink-3); }
.card-foot { padding: 12px 16px; display: flex; justify-content: flex-end; border-top: 1px solid var(--ys-line); background: linear-gradient(180deg, #fdfdfe, #fbfcfe); }
/* ---------- 全局规范表 ---------- */
.spec-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.spec-table td { padding: 7px 8px; border-bottom: 1px solid var(--ys-line-soft); color: var(--ys-ink-2); vertical-align: top; line-height: 1.6; }
.spec-table tr:last-child td { border-bottom: 0; }
.spec-table td:first-child { color: var(--ys-ink); font-weight: 500; white-space: nowrap; }
.spec-table td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.spec-table td.bad { color: var(--ys-ink-3); text-decoration: line-through; text-decoration-color: rgba(201, 79, 79, 0.35); }
.spec-table td.good { color: var(--ys-ink); }
/* ---------- 首页仪表盘(Bento Grid) ---------- */
.bento { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 102px; gap: 14px; }
.bento-card {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card);
padding: 17px 20px; display: flex; flex-direction: column;
/* 微动效:入场交错上浮 + hover 抬升 2px(prefers-reduced-motion 下全部关闭) */
animation: rise 320ms var(--ys-ease) backwards;
transition: transform 180ms var(--ys-ease), box-shadow 180ms var(--ys-ease);
}
.bento-card:hover { transform: translateY(-2px); box-shadow: var(--ys-shadow-raise); }
.b-head { display: flex; align-items: center; justify-content: space-between; }
.b-icon { width: 15px; height: 15px; color: var(--ys-ink-3); opacity: 0.8; }
.bento-card:nth-child(1) { animation-delay: 0ms; }
.bento-card:nth-child(2) { animation-delay: 45ms; }
.bento-card:nth-child(3) { animation-delay: 90ms; }
.bento-card:nth-child(4) { animation-delay: 135ms; }
.bento-card:nth-child(5) { animation-delay: 180ms; }
.bento-card:nth-child(6) { animation-delay: 225ms; }
.bento-card:nth-child(7) { animation-delay: 270ms; }
.bento-card:nth-child(8) { animation-delay: 315ms; }
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.bento-card, .login-card { animation: none; transition: none; }
}
.b-2x2 { grid-column: span 2; grid-row: span 2; }
.b-2x1 { grid-column: span 2; }
.b-eyebrow { font-size: 12px; font-weight: 500; letter-spacing: 0.04em; color: var(--ys-ink-2); }
.b-big { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.15; margin-top: auto; }
.b-big small { font-size: 13px; font-weight: 500; color: var(--ys-ink-3); margin-left: 4px; letter-spacing: 0; }
.b-num { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; line-height: 1.2; margin-top: auto; }
.b-num small { font-size: 12px; font-weight: 400; color: var(--ys-ink-3); margin-left: 3px; }
.b-sub { font-size: 11px; color: var(--ys-ink-3); margin-top: 3px; }
.b-up { color: var(--ys-ok); font-weight: 500; }
.b-todo { display: flex; align-items: baseline; gap: 8px; font-size: 13px; color: var(--ys-ink-2); }
.b-todo .cnt { color: var(--ys-indigo); font-weight: 600; font-variant-numeric: tabular-nums; }
/* ---------- 登录页 ---------- */
.login-shot { border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden; box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1); }
.login { display: grid; grid-template-columns: 460px 1fr; min-height: 520px; }
.login-brand {
background: linear-gradient(168deg, #2c4aa6 0%, #223a85 55%, #17275c 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
padding: 46px 44px; display: flex; flex-direction: column; justify-content: space-between;
position: relative; overflow: hidden;
}
.login-brand .brandmark { font-family: var(--ys-font-brand); font-size: 19px; font-weight: 500; letter-spacing: 0.36em; color: #fff; }
.login-brand .slogan { font-size: 15px; color: rgba(255, 255, 255, 0.82); margin-top: 14px; line-height: 1.9; }
.login-brand .foot { font-size: 12px; color: rgba(255, 255, 255, 0.42); letter-spacing: 0.08em; }
.ripple { position: absolute; inset: auto 0 0 0; height: 220px; pointer-events: none; }
.ripple i { display: block; height: 1px; margin-top: 26px; }
.ripple i:nth-child(1) { background: linear-gradient(90deg, rgba(255,255,255,0.36), rgba(255,255,255,0.02)); }
.ripple i:nth-child(2) { margin-top: 22px; width: 92%; background: linear-gradient(90deg, rgba(255,255,255,0.30), rgba(255,255,255,0.02)); }
.ripple i:nth-child(3) { margin-top: 18px; width: 84%; background: linear-gradient(90deg, rgba(255,255,255,0.25), rgba(255,255,255,0.02)); }
.ripple i:nth-child(4) { margin-top: 15px; width: 74%; background: linear-gradient(90deg, rgba(255,255,255,0.20), rgba(255,255,255,0.02)); }
.ripple i:nth-child(5) { margin-top: 12px; width: 62%; background: linear-gradient(90deg, rgba(255,255,255,0.15), rgba(255,255,255,0.02)); }
.ripple i:nth-child(6) { margin-top: 10px; width: 48%; background: linear-gradient(90deg, rgba(255,255,255,0.11), rgba(255,255,255,0.02)); }
.login-form {
background: linear-gradient(180deg, #ffffff, #fdfdff);
box-shadow: inset 0 1px 0 #fff;
padding: 46px 56px; display: flex; flex-direction: column; justify-content: center;
}
.login-form h2 { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
.login-form .hint { font-size: 13px; color: var(--ys-ink-3); margin: 6px 0 26px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; color: var(--ys-ink-2); margin-bottom: 6px; }
.field .input { width: 100%; height: 38px; }
.captcha-row { display: flex; gap: 10px; }
.captcha-row .input { flex: 1; }
.captcha-box {
width: 104px; height: 38px; border: 1px solid var(--ys-line); border-radius: var(--ys-radius-sm);
display: flex; align-items: center; justify-content: center;
font-family: 'SFMono-Regular', Consolas, monospace; font-size: 17px; letter-spacing: 0.28em;
color: var(--ys-indigo); background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04); user-select: none;
}
.login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; }
/* 方案 B:居中卡片 */
.login-center {
position: relative; overflow: hidden;
min-height: 480px; display: flex; align-items: center; justify-content: center;
background-color: #f2f4f8;
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
background-size: 24px 24px;
padding: 40px;
}
/* 顶部光晕:靛蓝的「天光」,柔和地洒在登录卡上 */
.login-center::before {
content: ''; position: absolute; top: -160px; left: 50%; transform: translateX(-50%);
width: 860px; height: 460px; pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
}
/* 背景涟漪:同心圆水波(「衍水」的品牌装饰,右上入画) */
.ripple-bg { position: absolute; top: -170px; right: -130px; width: 520px; height: 520px; pointer-events: none; }
.ripple-bg i {
position: absolute; inset: 0; border-radius: 50%;
border: 1px solid rgba(46, 75, 168, 0.09);
}
.ripple-bg i:nth-child(2) { inset: 65px; border-color: rgba(46, 75, 168, 0.07); }
.ripple-bg i:nth-child(3) { inset: 130px; border-color: rgba(46, 75, 168, 0.055); }
.ripple-bg i:nth-child(4) { inset: 195px; border: none; background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015)); }
.login-card {
position: relative; overflow: hidden;
animation: rise 320ms var(--ys-ease) backwards;
width: 400px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: 14px;
box-shadow: 0 2px 4px rgba(28, 35, 51, 0.04), 0 16px 40px rgba(28, 35, 51, 0.1);
padding: 30px 36px 32px;
}
.lc-brand { text-align: center; }
.lc-brand-name { font-family: var(--ys-font-brand); font-size: 17px; font-weight: 500; letter-spacing: 0.34em; color: var(--ys-ink); }
.lc-slogan { font-size: 12.5px; color: var(--ys-ink-3); margin-top: 8px; letter-spacing: 0.02em; }
.lc-form-head { font-size: 15px; font-weight: 600; margin-top: 26px; }
.lc-form-hint { font-size: 12px; color: var(--ys-ink-3); margin: 4px 0 18px; }
.lc-foot { text-align: center; margin-top: 16px; font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.08em; }
.login-card .input { width: 100%; height: 38px; }
.note { margin-top: 18px; font-size: 13px; color: var(--ys-ink-2); line-height: 1.9; }
.note b { color: var(--ys-ink); font-weight: 600; }
.states { display: flex; gap: 14px; margin-top: 12px; }
.state { text-align: center; }
.state .cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 6px; }
</style>
</head>
<body>
<div class="wrap">
<!-- ============ 封面 ============ -->
<header class="cover" id="sec-cover">
<div class="brand">衍水集团</div>
<h1>营销人员工资管理系统 · 视觉方向</h1>
<div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div>
<div class="waterline"></div>
<div class="tags">
<span class="tag">表面微渐变(受光)</span>
<span class="tag">半透明描边</span>
<span class="tag">分层阴影</span>
<span class="tag">内高光 / 内阴影</span>
<span class="tag">2% 噪点</span>
<span class="tag">等宽数字</span>
<span class="tag">120–180ms 轻动效</span>
</div>
</header>
<!-- ============ 色板 ============ -->
<section class="section" id="sec-palette">
<h2>01 · 色板与材质</h2>
<div class="swatches">
<div class="swatch">
<div class="chip" style="background:linear-gradient(180deg,#3a58bc,#2e4ba8)"></div>
<div class="meta"><div class="name">靛蓝</div><div class="hex num">#2E4BA8 ↑#3A58BC</div>
<div class="use">品牌色。按钮/链接/焦点/当前菜单,配微渐变与内高光</div></div>
</div>
<div class="swatch">
<div class="chip" style="background:linear-gradient(180deg,#f2f4f8,#ecedf3)"></div>
<div class="meta"><div class="name">侧栏灰</div><div class="hex num">#F2F4F8 → #ECEDF3</div>
<div class="use">侧栏微渐变(自上而下微沉)</div></div>
</div>
<div class="swatch">
<div class="chip" style="background:#f4f5f8"></div>
<div class="meta"><div class="name">雾灰</div><div class="hex num">#F4F5F8</div>
<div class="use">页面底</div></div>
</div>
<div class="swatch">
<div class="chip" style="background:linear-gradient(180deg,#ffffff,#fcfcfe);border-bottom:1px solid #e3e6ec"></div>
<div class="meta"><div class="name">纸白</div><div class="hex num">#FFFFFF → #FCFCFE</div>
<div class="use">卡片/表格:顶白底微灰的「受光」渐变</div></div>
</div>
<div class="swatch">
<div class="chip" style="background:#e4e7ec"></div>
<div class="meta"><div class="name">界线</div><div class="hex num">rgba(28,35,51,.09)</div>
<div class="use">1px 半透明描边:在不同底色上自然融合</div></div>
</div>
<div class="swatch">
<div class="chip" style="background:#1c2333"></div>
<div class="meta"><div class="name">墨色</div><div class="hex num">#1C2333</div>
<div class="use">主文字(冷调近黑)</div></div>
</div>
<div class="swatch">
<div class="chip" style="background:#5b6577"></div>
<div class="meta"><div class="name">次墨</div><div class="hex num">#5B6577</div>
<div class="use">次级文字、表头、说明</div></div>
</div>
<div class="swatch">
<div class="chip" style="background:linear-gradient(90deg,#1e9e6a,#c98a1b,#c94f4f)"></div>
<div class="meta"><div class="name">语义色</div><div class="hex num">#1E9E6A · #C98A1B · #C94F4F</div>
<div class="use">成功/警示/危险,浅底微渐变 + 深字</div></div>
</div>
</div>
</section>
<!-- ============ 字体与数字 ============ -->
<section class="section" id="sec-type">
<h2>02 · 字体与数字</h2>
<div class="type-sheet">
<div class="type-row"><span class="label">页面标题</span><span class="t-page-title">岗位角色</span><span class="label" style="width:auto">18px / 600 · 字距 −0.01em</span></div>
<div class="type-row"><span class="label">卡片标题</span><span class="t-card-title">计提参数 · 业务员个人户</span><span class="label" style="width:auto">15px / 600</span></div>
<div class="type-row"><span class="label">正文 / 表格</span><span class="t-body">回款天数 = 发货日首日起算,按自然日累计</span><span class="label" style="width:auto">14px / 400 · 13px / 400</span></div>
<div class="type-row"><span class="label">表头 / 标签</span><span class="t-th">岗位编码 公式模板 生效日期</span><span class="label" style="width:auto">12px / 500 · 字距 +0.04em</span></div>
<div class="type-row"><span class="label">品牌字</span><span style="font-weight:600;letter-spacing:.35em;font-size:15px">衍水集团</span><span class="label" style="width:auto">无衬线 600 · 大字距 · 仅品牌落款一处</span></div>
</div>
<div class="amount-demo">
<table>
<thead><tr><th>回款日期</th><th>回款金额(元)</th><th>回款天数</th><th>计提比例</th><th>绩效金额(元)</th></tr></thead>
<tbody>
<tr><td>2026-01-10</td><td>386,200.00</td><td>27</td><td>9.7‰</td><td>14,062.31</td></tr>
<tr><td>2026-02-18</td><td>1,240,000.00</td><td>361</td><td>4‰</td><td>19,840.00</td></tr>
<tr><td>合计</td><td>1,626,200.00</td><td>—</td><td>—</td><td>33,902.31</td></tr>
</tbody>
</table>
<div class="note">金额与比例一律<b>等宽数字(tabular-nums)右对齐</b>,合计行加粗放大半档——列对列、位对位,扫一眼就能核对。</div>
</div>
<div class="brand-candidates">
<div class="bc">
<div style="font-family:'Noto Sans SC',sans-serif;font-weight:500;letter-spacing:.32em;font-size:16px">衍水集团</div>
<div class="bc-cap">A · 思源黑体 500 · 0.32em <span class="bc-rec">当前应用</span></div>
</div>
<div class="bc">
<div style="font-family:'Noto Sans SC',sans-serif;font-weight:300;letter-spacing:.42em;font-size:16px">衍水集团</div>
<div class="bc-cap">B · 思源黑体 300 细体 · 0.42em</div>
</div>
<div class="bc">
<div style="font-family:'Microsoft YaHei',sans-serif;font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div>
<div class="bc-cap">C · 微软雅黑 600 · 0.25em</div>
</div>
</div>
</section>
<!-- ============ 组件 ============ -->
<section class="section" id="sec-comp">
<h2>03 · 组件基调</h2>
<div class="comp-grid">
<div class="comp-card">
<h3>按钮 · 三态</h3>
<div class="states">
<div class="state"><button class="btn btn-primary btn-ghost-hover">新增岗位</button><div class="cap">静置</div></div>
<div class="state"><button class="btn btn-primary" style="background:linear-gradient(180deg,#31509f,#243c8b);transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,255,255,.18),0 4px 12px rgba(46,75,168,.3)">新增岗位</button><div class="cap">悬停:抬升 1px · 影加深</div></div>
<div class="state"><button class="btn btn-primary" style="box-shadow:inset 0 1px 2px rgba(0,0,0,.18)">新增岗位</button><div class="cap">按下:回落 · 内阴影</div></div>
</div>
<div class="note">微渐变 + 内高光给按钮「实体感」;次级按钮一律白底细边框,无阴影污染。</div>
</div>
<div class="comp-card">
<h3>输入框 · 状态</h3>
<div class="row">
<input class="input" placeholder="搜索岗位编码或名称" />
<input class="input focused" value="4/360" />
</div>
<div class="row" style="margin-top:14px">
<span class="pill pill-ok">启用</span>
<span class="pill pill-off">禁用</span>
<span class="pill pill-warn">待生效</span>
</div>
<div class="note">输入框带微内阴影(凹陷暗示);聚焦 = 靛蓝描边 + 3px 柔光外环。</div>
</div>
<div class="comp-card">
<h3>分页</h3>
<div class="pager">
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">›</span>
<span style="margin-left:10px">共 28 条 · 每页 10 条</span>
</div>
<div class="note" style="margin-top:14px">当前页与主按钮同材质:微渐变 + 内高光。</div>
</div>
<div class="comp-card">
<h3>动效原则</h3>
<div class="note">
过渡 120–180ms,<b>只做颜色、阴影与 1px 内的位移</b>;弹层淡入 + 4px 上移;
数字与表格不加动画;尊重 <b>prefers-reduced-motion</b>。
动效的目标是「顺滑可信」,不是表演。
</div>
</div>
</div>
</section>
<!-- ============ 应用界面 ============ -->
<section class="section" id="sec-app">
<h2>04 · 应用界面(布局与列表页)</h2>
<div class="app-shot">
<div class="app">
<aside class="aside">
<div class="logo">衍水集团</div>
<div class="logo-sub">营销人员工资管理系统</div>
<div class="waterline" style="width:22px;margin:10px 10px 0;"></div>
<div class="menu-group">
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M2.5 7 8 2.5 13.5 7v6.5H10V9.5H6v4H2.5Z"/></svg>
首页
</div>
</div>
<div class="menu-group">
<div class="menu-label">系统管理</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
用户管理
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M8 2 13 4v4c0 3-2 5-5 6-3-1-5-3-5-6V4Z"/></svg>
角色管理
</div>
<div class="menu-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
岗位角色
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
计提参数
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4.5h8M4 8h8M4 11.5h5"/></svg>
操作日志
</div>
</div>
<div class="aside-foot">
<div class="waterline" style="width:22px;margin:0 0 8px;"></div>
<div class="aside-foot-text">内部系统 · v0.1</div>
</div>
</aside>
<div class="main">
<div class="topbar">
<div class="crumb">系统管理 <span>/</span> <b>岗位角色</b></div>
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
</div>
<div class="content">
<div class="page-head">
<div>
<h1>岗位角色</h1>
<div class="desc">决定人员适用的计提公式模板与参数;新增岗位无需改代码</div>
<div class="waterline"></div>
</div>
<button class="btn btn-primary">新增岗位</button>
</div>
<div class="filter">
<input class="input" placeholder="岗位编码或名称" />
<input class="input" placeholder="状态:全部" style="width:130px" />
<button class="btn btn-primary">查询</button>
<button class="btn btn-default">重置</button>
<span class="spacer"></span>
</div>
<div class="card">
<table class="grid">
<thead>
<tr><th>岗位编码</th><th style="width:110px">岗位名称</th><th>公式模板</th><th>说明</th><th style="width:90px">状态</th><th style="width:150px">操作</th></tr>
</thead>
<tbody>
<tr>
<td class="cell-code">salesperson</td><td>营销员</td>
<td class="cell-muted">业务员个人户 · 天数线性递减</td>
<td class="cell-muted">回款天数线性递减(360 天=6‰,361 天起 4‰)</td>
<td><span class="pill pill-ok">启用</span></td>
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
</tr>
<tr>
<td class="cell-code">sales_manager</td><td>营销经理</td>
<td class="cell-muted">营销经理 · 团队与全公司分成</td>
<td class="cell-muted">本团队 12.5% + 全公司 5%</td>
<td><span class="pill pill-ok">启用</span></td>
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
</tr>
<tr>
<td class="cell-code">info_officer</td><td>信息员</td>
<td class="cell-muted">信息员 · 销售额固定比例</td>
<td class="cell-muted">本人销售额 × 1‰</td>
<td><span class="pill pill-ok">启用</span></td>
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
</tr>
<tr>
<td class="cell-code">senior_sales</td><td>高级业务员</td>
<td class="cell-muted">业务员个人户 · 天数线性递减</td>
<td class="cell-muted">自定义岗位(新增岗位无需改代码)</td>
<td><span class="pill pill-off">禁用</span></td>
<td class="cell-actions"><a>编辑</a><a>删除</a></td>
</tr>
</tbody>
</table>
<div class="card-foot">
<div class="pager">
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">›</span>
<span style="margin-left:10px">共 4 条</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
质感的三层材料:<b>侧栏</b>灰渐变(沉)→ <b>内容底</b>雾灰 + 顶部受光 → <b>卡片</b>纸白微渐变 + 半透明描边 + 分层阴影。
当前项白底浮起,白色被灰托住,不再扎眼。
</div>
</section>
<!-- ============ 首页仪表盘 ============ -->
<section class="section" id="sec-bento">
<h2>05 · 首页仪表盘(Bento Grid)</h2>
<div class="app-shot">
<div class="app">
<aside class="aside">
<div class="logo">衍水集团</div>
<div class="logo-sub">营销人员工资管理系统</div>
<div class="waterline" style="width:22px;margin:10px 10px 0;"></div>
<div class="menu-group">
<div class="menu-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M2.5 7 8 2.5 13.5 7v6.5H10V9.5H6v4H2.5Z"/></svg>
首页
</div>
</div>
<div class="menu-group">
<div class="menu-label">系统管理</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
用户管理
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M8 2 13 4v4c0 3-2 5-5 6-3-1-5-3-5-6V4Z"/></svg>
角色管理
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
岗位角色
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
计提参数
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4.5h8M4 8h8M4 11.5h5"/></svg>
操作日志
</div>
</div>
<div class="aside-foot">
<div class="waterline" style="width:22px;margin:0 0 8px;"></div>
<div class="aside-foot-text">内部系统 · v0.1</div>
</div>
</aside>
<div class="main">
<div class="topbar">
<div class="crumb"><b>仪表盘</b></div>
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
</div>
<div class="content">
<div class="page-head">
<div>
<h1>仪表盘</h1>
<div class="desc">数据截至今日 06:00 · 12 笔回款待核算</div>
<div class="waterline"></div>
</div>
</div>
<div class="bento">
<div class="bento-card b-2x2">
<div class="b-eyebrow">本月绩效总额</div>
<div class="b-big" style="color:var(--ys-indigo)">268,430.16<small>元</small></div>
<div class="b-sub">较上月 <span class="b-up">+12.4%</span> · 已核销 86 笔</div>
<div id="chartPerf" style="width:100%;height:40px;margin-top:12px"></div>
</div>
<div class="bento-card b-2x2">
<div class="b-eyebrow">近 6 个月回款</div>
<div class="b-sub" style="margin-top:2px">单位:万元</div>
<div id="chartRev" style="width:100%;height:78px;margin-top:auto"></div>
</div>
<div class="bento-card b-2x1">
<div class="b-eyebrow">待办</div>
<div style="margin-top:auto;display:flex;flex-direction:column;gap:4px">
<div class="b-todo"><span class="cnt">12 笔</span>回款待核算</div>
<div class="b-todo"><span class="cnt">3 笔</span>业务归属待确认</div>
</div>
</div>
<div class="bento-card">
<div class="b-head"><div class="b-eyebrow">营销人员</div>
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
</div>
<div class="b-num">36<small>人</small></div>
</div>
<div class="bento-card">
<div class="b-head"><div class="b-eyebrow">岗位角色</div>
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
</div>
<div class="b-num">3<small>个</small></div>
</div>
<div class="bento-card b-2x1">
<div class="b-eyebrow">常用操作</div>
<div class="row" style="margin-top:auto">
<button class="btn btn-primary" style="height:30px;font-size:12px;padding:0 13px">录入回款</button>
<button class="btn btn-default" style="height:30px;font-size:12px;padding:0 13px">计提参数</button>
<button class="btn btn-default" style="height:30px;font-size:12px;padding:0 13px">岗位角色</button>
</div>
</div>
<div class="bento-card">
<div class="b-head"><div class="b-eyebrow">计提参数</div>
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
</div>
<div class="b-num">16<small>项</small></div>
<div class="b-sub">版本 P20260101-00</div>
</div>
<div class="bento-card">
<div class="b-head"><div class="b-eyebrow">T+ 同步</div>
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M13 8a5 5 0 1 1-1.5-3.5"/><path d="M13 2.5V5h-2.5"/></svg>
</div>
<div class="b-num" style="color:var(--ys-ok)">正常</div>
<div class="b-sub">每周一 06:00</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
<b>Bento Grid</b>:大小不一的卡片按重要性排布——绩效总额与回款趋势占 2×2 主角位,数字型指标 1×1 紧凑排列。
卡片入场为 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、阴影加深——打开预览即可看到微动效。
</div>
</section>
<!-- ============ 登录页 ============ -->
<section class="section" id="sec-login">
<h2>06 · 登录页(重做)</h2>
<div class="login-shot">
<div class="login-center">
<div class="ripple-bg"><i></i><i></i><i></i><i></i></div>
<div style="position:relative">
<div class="login-card">
<div class="lc-brand">
<div class="lc-brand-name">衍水集团</div>
<div class="lc-slogan">把每一笔回款,算成一份踏实。</div>
<div class="waterline" style="width:40px;margin:14px auto 0"></div>
</div>
<div class="lc-form-head" style="margin-top:28px">欢迎回来</div>
<div class="lc-form-hint">请输入账号密码进入系统</div>
<div class="field">
<label>用户名</label>
<input class="input" placeholder="请输入用户名" />
</div>
<div class="field">
<label>密码</label>
<input class="input" type="password" placeholder="请输入密码" />
</div>
<div class="field">
<label>验证码</label>
<div class="captcha-row">
<input class="input" placeholder="请输入验证码" />
<div class="captcha-box">7K3P</div>
</div>
</div>
<button class="btn btn-primary login-btn">登 录</button>
</div>
<div class="lc-foot">© 衍水集团 · 内部系统</div>
</div>
</div>
</div>
<div class="note">
背景三层:<b>点阵网格</b>(秩序)+ <b>顶部靛蓝天光</b>(氛围)+ <b>右上涟漪同心圆</b>(「衍水」的品牌装饰);
卡片内品牌区承担门面——品牌字、标语、水线;表单以「欢迎回来」承接。装饰与文案都来自「衍水」本身。
</div>
</section>
<!-- ============ 签名元素 ============ -->
<section class="section" id="sec-sign">
<h2>07 · 签名元素:水线</h2>
<div class="comp-card">
<div style="font-size:18px;font-weight:600;letter-spacing:-0.01em">计提参数</div>
<div class="waterline" style="width:56px"></div>
<div class="note" style="max-width:720px">
页面标题下的<b>渐隐靛蓝细线(56px,左实右虚)</b>——「衍水」之水的提纯,全站唯一的装饰性元素,
只在每个页面标题处出现一次。它与登录页的水纹层理同源:<b>品牌连续,而非推倒重来</b>。
</div>
</div>
</section>
<!-- ============ 微动效与精致文案(全局规范) ============ -->
<section class="section" id="sec-motion">
<h2>08 · 微动效与精致文案(全局规范)</h2>
<div class="comp-grid">
<div class="comp-card">
<h3>微动效 · 全站统一</h3>
<table class="spec-table">
<tr><td>页面 / 卡片入场</td><td>淡入 + 上浮 6px,45ms 交错</td><td class="num">320ms</td></tr>
<tr><td>按钮悬停 / 按下</td><td>抬升 1px、影加深 / 回落内阴影</td><td class="num">150ms</td></tr>
<tr><td>卡片悬停</td><td>抬升 2px、阴影加深</td><td class="num">180ms</td></tr>
<tr><td>输入聚焦</td><td>靛蓝描边 + 3px 柔光外环</td><td class="num">150ms</td></tr>
<tr><td>弹层 / 抽屉</td><td>淡入 + 上移 4px</td><td class="num">160ms</td></tr>
<tr><td>表格行</td><td>底色渐显(无位移)</td><td class="num">120ms</td></tr>
<tr><td>数据刷新</td><td>数字不做滚动动画(核对场景,动即干扰)</td><td class="num">—</td></tr>
<tr><td>无障碍</td><td>prefers-reduced-motion 时全部关闭</td><td class="num">—</td></tr>
</table>
<div class="note">目标不是「有动画」,而是<b>顺滑可信</b>:每次操作都有轻微、及时、可预期的反馈。</div>
</div>
<div class="comp-card">
<h3>精致文案 · 全站统一</h3>
<table class="spec-table">
<tr><td>按钮</td><td class="bad">提交</td><td class="good">保存变更 · 录入回款</td></tr>
<tr><td>空态</td><td class="bad">暂无数据</td><td class="good">还没有岗位角色,点「新增岗位」创建第一个</td></tr>
<tr><td>错误</td><td class="bad">操作失败</td><td class="good">岗位编码已存在,请换一个</td></tr>
<tr><td>危险确认</td><td class="bad">确定删除吗?</td><td class="good">删除后无法恢复,该岗位下的人员需重新归属</td></tr>
<tr><td>成功提示</td><td class="bad">操作成功</td><td class="good">岗位已创建 · 参数 11-01 起生效</td></tr>
<tr><td>页面描述</td><td class="bad">本页面用于管理</td><td class="good">决定人员适用的计提公式模板与参数;新增岗位无需改代码</td></tr>
</table>
<div class="note">说清楚<b>会发生什么</b>;动词开头、句子短;不道歉、不卖萌;<b>空态是邀请,错误是路标</b>。</div>
</div>
</div>
</section>
</div>
<script src="echarts.min.js"></script>
<script>
/* 仪表盘图表:ECharts 定制主题(圆角柱 / 无网格线 / 浅灰轴标签)——实施时前端引入 echarts 依赖 */
const chartFont = "'PingFang SC','Microsoft YaHei','Noto Sans SC',sans-serif";
const rev = echarts.init(document.getElementById('chartRev'), null, { renderer: 'svg' });
rev.setOption({
grid: { left: 0, right: 0, top: 6, bottom: 20, containLabel: true },
xAxis: {
type: 'category', data: ['5月', '6月', '7月', '8月', '9月', '10月'],
axisLine: { show: false }, axisTick: { show: false },
axisLabel: { color: '#9aa3b2', fontSize: 9, margin: 10, fontFamily: chartFont }
},
yAxis: { type: 'value', show: false },
series: [{
type: 'bar', barWidth: 22, data: [34, 42, 30, 48, 38, 56],
itemStyle: {
borderRadius: [3, 3, 0, 0],
color: (p) => p.dataIndex === 5
? new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#3a58bc' }, { offset: 1, color: '#2e4ba8' }])
: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#d5def3' }, { offset: 1, color: '#e8edf8' }])
}
}]
});
const perf = echarts.init(document.getElementById('chartPerf'), null, { renderer: 'svg' });
perf.setOption({
grid: { left: 0, right: 0, top: 6, bottom: 2 },
xAxis: { type: 'category', show: false, boundaryGap: false, data: [1, 2, 3, 4, 5, 6, 7] },
yAxis: { type: 'value', show: false, min: 0 },
series: [{
type: 'line', data: [8, 13, 10, 19, 23, 29, 33], smooth: 0.4, symbol: 'none',
lineStyle: { color: '#2e4ba8', width: 1.6 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(46,75,168,.10)' }, { offset: 1, color: 'rgba(46,75,168,0)' }
])
}
}]
});
</script>
</body>
</html>
@@ -0,0 +1,268 @@
# M1 开发计划:系统设置与参数体系(#28)
## Context
《剩余开发计划》已定稿(`docs/superpowers/plans/2026-10-12-剩余开发计划.md`),M1 是首个工作块。本文档即 M1 的正式开发计划(2026-10-12 批准):涵盖设计(四表/参数/模板/接口)、任务分解(T1–T8 步骤级)、执行安排与验收标准;开工后按第二章逐任务执行。
**为什么先做 M1**:岗位角色被基础数据的人员管理引用(外键上游);参数快照与公式模板是绩效核算的输入契约。先立地基可避免后续模块加表、加外键、改表单的连锁返工;计算预览还可作为业务对账工具先行验收公式口径。
**#28 需求原文要点**:① 人员岗位角色(营销员/营销经理/信息员等)支持增删,新增岗位时在系统设置中绑定计提公式与参数,**无需改代码**;② 全部计提比例、系数、阈值、分成比例、起付比例统一在系统设置维护,**支持版本管理与留痕**。
## 现状(开工前已核查)
| 项 | 状态 |
| --- | --- |
| 分支 | `feat/system-params`(基于 `feature/system-management` HEAD;后者领先 main 5 提交、未合并——是否先合并 main 由用户决定,不阻塞开发) |
| 容器 | Docker Desktop 位于 `D:\work\app\Docker\Docker Desktop.exe`(非默认路径) |
| 工作区 | docs/ 下有用户侧整理改动(业务清单删除、v2 新增、superpowers 目录),与 M1 无关,不动 |
| 基线 | 301 用例全绿(系统管理交付时);开工先复跑确认 |
## 范围
**做**:四张新表与种子(V3 迁移双轨);岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数与模板注册表;计算预览;前端两个页面(岗位角色、计提参数)。
**不做**:核算引擎与台账(M3);基础数据与业务数据(M2);参数 Redis 缓存(一期不做,保留键位);规则引擎(既有决策)。
---
## 一、技术设计
### 1.1 数据模型(四张新表,V3 迁移)
**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明)
| 列 | 类型 | 约束/说明 |
| --- | --- | --- |
| id | BIGINT UNSIGNED | PK AUTO_INCREMENT |
| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改 |
| role_name | VARCHAR(64) | NOT NULL |
| calc_template | VARCHAR(32) | NOT NULL,公式模板编码(以代码注册表为准) |
| description | VARCHAR(255) | NULL |
| sort_order | INT | NOT NULL DEFAULT 0 |
| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) |
| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) |
| create_time / update_time | DATETIME | TimestampMixin(update 用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")`) |
**`param_definitions` 参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天);`dimension_type` VARCHAR(32) DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value`/`value_min`/`value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。
**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(`P{YYYYMMDD}-{当日序号}`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT DEFAULT 0;仅 `create_time`(append-only,不继承 TimestampMixin)。
**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`(用空串不用 NULL);`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64);`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。热路径索引 `idx_param_values_param_id_dimension_value_effective_date`。已生效条目禁改禁删;未生效批次可整批撤回。
**种子**(迁移 + init-dev 双写):3 个内置岗位(`salesperson 营销员`/TPL_SALESPERSON_DECAY、`sales_manager 营销经理`/TPL_MANAGER_SHARE、`info_officer 信息员`/TPL_INFO_SALES_FIXED)+ 16 项参数定义 + 版本条目 + 一条初始批次(`P20260101-00`)。
### 1.2 参数清单(16 项)
| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 生效日期 |
| --- | --- | --- | --- | --- | --- |
| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 2026-02-17 |
| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 2026-02-17 |
| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 2026-02-17 |
| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 2026-02-17 |
| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | 2026-08-13 |
| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 2026-08-13 |
| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | **2(02-17 起)/ 3(08-13 起)** | 双行 |
| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | 2026-08-13 |
| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | 2026-01-01 |
| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | 2026-01-01 |
| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | 2026-01-01 |
| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | 2026-01-01 |
| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | 2026-02-17 |
| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | 2026-01-01 |
| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | 2026-01-01 |
| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 2026-01-01 |
**不设参数**(作废条款落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。
**值校验规则**(`validate_and_normalize`):DECIMAL 用 `Fraction(text)` 解析(拒绝 `1/0`、非数字),并按 `value_min`/`value_max` 校验;INT 必须整数;BOOL 仅 0/1;ENUM 必须在 `options_text` 内;规范化去首尾空白、分数式原样存储。
### 1.3 公式模板与消费契约
**模板注册表**(`app/services/calc_templates.py`,代码内有限集合,一期不引入规则引擎的落地方式):
| 模板编码 | 形态 | 声明参数 |
| --- | --- | --- |
| TPL_SALESPERSON_DECAY | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary |
| TPL_MANAGER_SHARE | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* |
| TPL_ENTERPRISE_FIXED | 乘法:折算毛利润额 × 比例 | enterprise.rate |
| TPL_INFO_SALES_FIXED | 乘法:本人销售额 × 比例 | info.rate |
| TPL_PREPAY_FIXED | 乘法:回款额 × 比例 | prepay.rate |
修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目;新增岗位 = 新增行 + 选模板(零代码)。
**消费接口契约**(M3 核算与预览共用):
```python
# app/services/param_service.py
@dataclass(frozen=True, slots=True)
class ParamSnapshot:
biz_date: date
def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链:角色 > 产品类别 > 默认
def fraction(self, code, **dims) -> Fraction: ...
def integer(self, code, **dims) -> int: ...
def boolean(self, code, **dims) -> bool: ...
def text(self, code, **dims) -> str: ...
def resolve_snapshot(session, biz_date: date) -> ParamSnapshot
def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut
def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut]
# app/services/formula.py(纯函数,禁 float)
def payment_days(ship_date, receipt_date) -> int # 发货日首日=1
def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零
def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值)
def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3)
def apply_barter(amount, discount) -> Decimal
def payment_weight(r: int) -> Fraction # r×r
def start_payment_ok(received, revenue, ratio) -> bool
```
### 1.4 API 设计(/api/v1/system/*,AdminUser)
**岗位角色**:`GET/POST /business-roles`、`GET/PUT/DELETE /business-roles/{id}`、`PUT /business-roles/{id}/status`、`GET /business-roles/meta/templates`(模板下拉,含声明参数说明)。
**计提参数**:`GET /params`(目录+当前快照,支持 `asOf`)、`GET /params/{param_code}/history`、`POST /params/changes`(提交变更批次)、`DELETE /params/changes/{batch_id}`(撤回未生效)、`GET /params/changes`(记录分页)、`GET /params/changes/{batch_id}`(明细)、`GET /params/compare`(两日期对比)、`GET /params/export`(Excel,`StreamingResponse` 非统一信封)、`POST /params/preview`(计算预览)。
**关键示例 1:提交变更**
```json
// POST /api/v1/system/params/changes
{
"changeType": 2,
"effectiveDate": "2026-11-01",
"remark": "四季度系数调整",
"items": [
{ "paramCode": "enterprise.rate", "dimensionValue": "", "value": "3" },
{ "paramCode": "product.threshold", "dimensionValue": "rail_transit", "value": "22" }
]
}
// data:
{ "batchId": 12, "versionNo": "P20261012-01", "effectiveDate": "2026-11-01",
"changeType": 2, "itemCount": 2, "operatorName": "admin", "createTime": "..." }
```
**关键示例 2:计算预览**(示意值已验算)
```json
// POST /api/v1/system/params/preview
{ "businessRole": "salesperson", "customerType": "personal",
"productCategory": "municipal_water", "shipDate": "2025-12-15",
"receiptDate": "2026-01-10", "revenue": "100000", "profit": "45000",
"receivedAmount": "60000", "barter": false, "probation": false }
// data:
{ "paymentDays": 27, "rate": "97/10000", "rateText": "9.7‰(10‰ − 27 天 × 4/360‰)",
"profitRate": "9/20", "convertedProfit": "15000", "performanceAmount": "145.5",
"startPaymentOk": true, "paymentWeightR": 60, "paymentWeight": "9/25",
"payableAmount": "52.38", "paramVersion": "P20260101-00", "paramEffectiveDate": "2026-02-17" }
```
### 1.5 前端设计
**岗位角色页**(`views/system/business-roles/index.vue`):列表(复用 `usePagedList`/`AppPagination`/`StatusTag`)+ 新增/编辑弹窗(模板下拉,选中后展示模板声明的参数清单与当前值)+ 启停/删除(内置岗位按钮禁用并 title 提示原因)。路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。
**计提参数页**(`views/system/params/index.vue`):按 `param_group` 分组展示(名称/当前值+单位/生效日期/口径说明);编辑后进入「待提交」区,顶部选生效方式(立即/指定日期)+ 备注;「提交变更」二次确认(列出每项 旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + Excel 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」)。路由 meta order 6。导出接口走 axios 实例取 blob(非统一信封,api 文件内注明)。
---
## 二、任务分解(T1–T8)
任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。每任务遵循「写测试 → 确认失败 → 实现 → 确认通过 → commit」。
### T1 迁移与模型(四表 + 种子)
**文件**:`app/models/business_role.py`、`app/models/param.py`(创建);`app/models/__init__.py`(改);`alembic/versions/xxxx_*.py`(autogenerate+种子);`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql`(创建)。
- [ ] 写两个模型文件(参照 `app/models/role.py` 写法;`ParamChangeBatch`/`ParamValue` 不继承 TimestampMixin)
- [ ] 注册进 `models/__init__.py`(漏注册会让 autogenerate 生成删表迁移)
- [ ] `uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表"` → 人工检查命名/注释/ON UPDATE → 补种子(3 岗位/16 定义/版本条目/初始批次)
- [ ] `uv run alembic upgrade head` → `uv run alembic upgrade <V2_rev>:head --sql > db/migration/V3__*.sql`
- [ ] 同步 `db/init/init-dev.sql`(建表 + 种子 + alembic_version 更新)
- [ ] 写 `db/queries/计提参数.sql`(时点化解析与对比查询留档)
- [ ] 验证:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿
- [ ] Commit:`feat(system): 新增岗位角色与计提参数四表及 V3 迁移`
### T2 参数注册表 + 公式纯函数
**文件**:`app/services/param_registry.py`、`calc_templates.py`、`formula.py`(创建);`app/core/constants.py`(改);`tests/unit/test_formula.py`、`test_param_registry.py`。
- [ ] 写失败测试(关键断言):`salesperson_rate(360,...) == Fraction(6,1000)`;`361 天 == Fraction(4,1000)`;`第 1 天 == Fraction(10) - Fraction(1,90)`;`payment_days(2025-12-15, 2026-01-10) == 27`;同日回款 == 1;`converted_profit(0,...) == 0`(防除零);负绩效不归零;`payment_weight(60) == Fraction(36,100)`;`parse_fraction("4/360") == Fraction(1,90)`;注册表 16 项且模板声明参数均存在
- [ ] 运行确认失败 → 实现三个文件 → 运行 `uv run pytest tests/unit -q` 全绿
- [ ] Commit:`feat(system): 计提公式模板注册表与精确分数计算函数`
### T3 参数仓储 + 取值服务(时点化解析)
**文件**:`app/repositories/param_repository.py`、`app/services/param_service.py`(解析部分);`tests/unit/test_param_snapshot.py`。
- [ ] 写失败测试:覆盖链(角色 > 产品类别 > 默认);未定义参数按注册表默认值兜底;同日多次变更取 id 最大
- [ ] 实现 `resolve_values(session, biz_date)`(一条 SQL 取全量快照)+ `ParamSnapshot`/`resolve_snapshot` → `uv run pytest tests/unit -q` 全绿
- [ ] Commit:`feat(system): 计提参数按业务日期时点化取值服务`
### T4 岗位角色 CRUD 后端(含审计与保护规则)
**文件**:`app/repositories/business_role_repository.py`、`app/schemas/business_role.py`、`app/services/business_role_service.py`、`app/api/v1/system/business_roles.py`(创建);`app/api/router.py`、`app/core/constants.py`(改)。整体参照 `roles` 模块模式。
- [ ] 实现(保护规则:内置禁删禁停、code 不可改、模板必须存在于注册表、人员引用计数钩子预留;写操作包 `audit_service.audit()`)
- [ ] 冒烟:新增自定义岗位 ✓、删除内置 → 403、非法模板 → 400
- [ ] Commit:`feat(system): 业务岗位角色管理接口与保护规则`
### T5 参数变更/历史/对比/导出/预览后端
**文件**:`app/schemas/param.py`、`app/api/v1/system/params.py`(创建);`param_service.py` 扩展、`app/api/router.py`、`constants.py`(改);`pyproject.toml`(`uv add openpyxl`)。
- [ ] 实现 9 个端点(提交/撤回/历史/分页/明细/对比/导出/预览/目录快照);撤回已生效批次 → 400;导出列含变更人/时间/项/旧值/新值/生效日期
- [ ] 冒烟链路:提交(指定未来日期)→ 当前快照不变 → 立即生效提交 → 快照变化 → 撤回未生效 → 回退 → 预览 360/361 → 导出可打开
- [ ] Commit:`feat(system): 计提参数版本管理、变更留痕与计算预览接口`
### T6 前端岗位角色页
**文件**:`src/api/businessRoles.ts`、`src/views/system/business-roles/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。
- [ ] 实现页面与路由 → `pnpm type-check && pnpm build` 通过
- [ ] Commit:`feat(system): 前端岗位角色管理页面`
### T7 前端参数配置页(版本管理 + 预览)
**文件**:`src/api/params.ts`、`src/views/system/params/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。
- [ ] 实现页面(分组表/生效方式/二次确认/版本抽屉/预览抽屉)→ `pnpm type-check` 通过
- [ ] e2e 链路:改参数 → 变更记录出现 → 预览值变化 → 导出可下载
- [ ] Commit:`feat(system): 前端计提参数配置与版本管理页面`
### T8 模块测试统一编写与文档
**文件**:`tests/unit/test_param_service.py`、`tests/integration/test_business_roles.py`、`test_param_management.py`(创建);`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`、`frontend/e2e/*`(改)。
- [ ] 集成用例:参数精度全链路(注册表→种子→解析→预览一致);未来生效不泄漏;撤回回退;同日多次变更;审计留痕;权限 403;岗位保护规则
- [ ] 全量回归 `uv run pytest -q`(301 基线 + 新增全绿);e2e;三份 README 更新
- [ ] Commit:`test(system): 参数体系模块用例与测试记录`
---
## 三、执行安排
- **分支**:`feat/system-params`(已创建);`feature/system-management` 是否先合并 main 由用户决定,不阻塞
- **环境**:启动 `D:\work\app\Docker\Docker Desktop.exe` → `docker compose -f docker/docker-compose.dev.yml up -d`;T2 纯函数开发不依赖容器
- **方式**:原生执行(当前会话逐任务推进);每任务后跑验证命令;T8 全量回归;收尾一轮 code review
- **注意**:Windows uvicorn `--reload` 偶发卡死——验证 reload 以「新 worker 的 startup complete」为准;开放问题 7 项(见下)按默认处理,不阻塞
## 四、验收标准(M1 完成的定义)
1. 管理员可新增岗位并绑定公式模板(无需改代码);内置岗位禁删禁停
2. 任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录(批次 + 操作日志双保险)
3. 任意业务日期可解析出当时快照(08-12 企业户=2‰、08-13=3‰ 回溯正确)
4. 计算预览精确复现:360 天=6‰、361 天=4‰、12-15→01-10=27 天
5. `uv run pytest -q` 301 基线 + 新增全绿;`pnpm type-check && pnpm build` 通过;e2e 链路通过
6. `db/migration/V3__*.sql` 与 `db/init/init-dev.sql` 双轨同步(`test_schema_sync.py` 把关)
## 五、风险与开放问题
**风险(已定对策)**:参数缓存一期不做(保留 `ys:param.ver:*` 键位,未来按批次版本号精确失效);同日多次变更 UI 明示「按最新一条生效」;`4/360` 展示注明精确值;模板扩展摩擦一期接受(受控下拉);预览出参与需规 2.4.1 明细列同名(减少 M3 映射);openpyxl 在 T5 增补依赖。
**开放问题(不阻塞,按默认处理)**:① 第 1 天 10‰ vs 9.9889‰ → 按公式实现并参数化;② 企业户 2‰ 历史种子 → 已双种子;③ BALANCE 模式语义 → M3 前确认;④ `probation.salary.basic` 归属 → M2 时复核;⑤ 参数查询权限 → 暂 AdminUser;⑥ 导出列集合 → 以需规 2.3.1(四) 为准;⑦ 经理个人户业绩进入 12.5%+5% 的衔接 → **M3 开工前必须确认**。
## 六、验证方式
1. 按 T1–T8 的验证节点逐任务执行(见第二章各任务);T8 后全量回归 + e2e;
2. 验收标准 6 条逐条核对(见第四章);
3. 与 `ai-aux/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。
@@ -0,0 +1,607 @@
# 剩余开发计划(路线图 + 首工作块实施计划)
> **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。
**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。
**架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。
**技术栈:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0(同步)+ MySQL 8.0 + Redis 7;前端 Vue 3.5 + TS 5.9 + Vite 8 + Element Plus 2.14 + Pinia 4。
**规格:** `ai-aux/AI_CONTEXT.md`「需求裁决」节(权威口径);`docs/问题与解决方案对照表_v2.xlsx`(28 项裁决);`docs/2026.9.8修改方案.docx`(公式与生效日期)。需规(`docs/衍水绩效管理系统功能需规.docx`)中 4 类作废条款不再引用:26 天示例、含运费公式、协助 25%、经理个人户 8‰。
## 全局约束
- 表:小写蛇形复数、列小写蛇形、每列中文 COMMENT、主键 `id`、`uk_`/`idx_`/`fk_` 前缀;金额类 DECIMAL;时间列用 `TimestampMixin`(`ON UPDATE CURRENT_TIMESTAMP` 须 `text()` 显式写);append-only 表不继承 TimestampMixin(与 `login_logs` 先例一致)
- 迁移双轨:Alembic 生成迁移 → 导出 SQL 到 `db/migration/V{n}__*.sql`(按序编号)→ 同步 `db/init/init-dev.sql`;`tests/integration/test_schema_sync.py` 拦截漂移
- 六层红线:api 只参数绑定(写 `def` 非 `async def`)→ services 唯一事务边界 → repositories 只 `flush()`;统一响应 `{code,message,data}`,分页 `pageNum/pageSize` → `list/total/pageNum/pageSize`
- 写操作接入 `audit_service.audit()`;审计脱敏在格式化后执行(顺序不可倒)
- 金额全程 `Decimal`/`Fraction`,**禁 float**;`4/360` 按精确分数存储(`"4/360"`,禁 `0.011111`)
- Redis 键前缀 `ys:`,TTL 按业务语义;禁 `KEYS *` / `FLUSHALL` / `FLUSHDB`
- 前端:TS 锁 `~5.9.3`(不可 7.x);复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`;视觉沿用 `--ys-*` token;新页面必须写 `meta.title`
- 测试:业务模块整体功能完成时统一编写用例;记录写入 `backend/tests/README.md` / `frontend/README.md`
- 提交:`feat(scope): 中文说明`;不直推 main/develop
## 审查重点(Review Focus)
以下输入/失败模式最可能伤到使用者,对应测试已钉进第二章各任务:
1. 畸形参数值(`"1/0"` 除零分数、非数字、超取值域)→ 保存必须 400 拒绝且不落库(T2/T5)
2. 指定未来生效的变更泄漏进当前快照 → 快照解析必须不含(T3/T5)
3. 撤回未生效批次后解析未回退 → 必须回退到撤回前值(T5)
4. 回款天数边界:同日回款 = 1 天;12-15 → 01-10 = 27 天;跨年连续(T2)
5. 计提比例边界:360 天 = 6‰、361 天 = 4‰、第 1 天 = 10 − 4/360 ≈ 9.9889‰(精确分数断言)(T2)
6. 毛利润额 = 0 → 折算额 0、绩效 0 不除零;负绩效按公式正常计算不归零(T2)
---
## 第一章 模块级路线图
### 1.1 推荐顺序与依赖
```text
① 系统设置与参数体系(#28) ← 地基:岗位角色 + 参数 + 公式模板
② 基础数据管理 ← 人员引用 ① 岗位角色(外键上游)
③ 业务数据管理 ← 引用 ② 人员/产品/客户
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;⑥ 写入 ②③ 目标表,受 IT 字段映射制约
```
### 1.2 顺序论证(要点)
**① 先于基础数据:** 外键方向在上游(人员→岗位,先建避免迁移返工);参数取值契约是 ④ 的输入面,先定型让后续模块按契约编码;计算预览(需规 2.3.1 四)首块即可交给业务对账「360 天=6‰、361 天=4‰」,验收前移;参数清单提炼本身 = 28 项裁决逐条复核。阈值参数归 ① 有明文依据(#28「全部阈值统一在系统设置维护」)。
**⑥ 放最后:** 字段映射未定(对照表 #26 ⏳IT),放前部会把外部不确定性引入关键路径;目标表由 ②③ 定稿后同步才有意义;一期可手工录入闭环。**但预留前移**:②③ 表设计必须含外部唯一键列(`source_no`/`t_plus_id`)、同步批次时间列,并约定「同步不覆盖人工字段(指导价、归属、岗位)」。
**⑤ 可后置:** 纯消费端无下游,口径依赖真实核算数据校准;唯一约束是 ④ 设计台账时对照需规 2.4.1 明细列(合同编号/发货日/回款日/回款天数/产品类别/客户类型/人员岗位/计提比例/支付权重R/绩效金额)一次性建全。
### 1.3 各块范围与验收标准
| 块 | 范围界定 | 验收标准(一句话) |
| --- | --- | --- |
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览 | 管理员可新增岗位并绑定模板;任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录;任意业务日期可解析当时快照;预览精确复现 360 天=6‰、361 天=4‰;301 基线不回归 |
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更 | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器 | 一笔业务可多产品多批次发货、多笔回款;回款天数按「发货日首日=1」自动算;折算走精确分数;评级只出等级不影响金额 |
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕 | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成 |
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致 |
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
### 1.4 里程碑(交付批次)
| 批次 | 内容 | 说明 |
| --- | --- | --- |
| **M1** | ① 系统设置与参数体系 | 独立可交付;预览工具可作业务对账入口先行验收 |
| **M2** | ② + ③ | 合并交付:一次表结构评审把 T+ 预留列与报表字段清单同时定稿 |
| **M3** | ④ 绩效工资核算 | 核心钱袋子;测试重点在精度与参数回溯 |
| **M4** | ⑤ 报表 + ⑥ T+ 同步(并行) | ⑥ 受 IT 制约,谁先就绪谁先交 |
### 1.5 需求依据对照
| 块 | 依据 | 关联作废条款(不引用) |
| --- | --- | --- |
| ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24 | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) |
| ② | 需规 2.1.1–2.1.3;对照表 #9/#19/#26 | 信息部负责人条款(信息员独立角色) |
| ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25 | 26 天示例;含运费公式(2.2.4/2.3.3 整段) |
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24 | 协助 25%;经理 8‰;26 天示例;含运费公式 |
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R) | — |
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
---
## 第二章 首工作块「系统设置与参数体系(#28)」实施计划
### 2.1 关键设计取舍(已定)
1. **参数存储 = 参数定义 + 逐项版本条目(时点化 append-only)+ 变更批次表承载「版本」**;否决全量快照(改一项复制全量、单参数历史需遍历)与单行 JSON(无回溯、无字段校验);
2. 数值一律以 **Fraction 可解析字符串**存储(`"4/360"`,禁 `0.011111`),解析走 `Fraction(text)` 不经 float;
3. **不设日期唯一键**:同日多次修正是合法留痕,解析按 `effective_date DESC, id DESC` 取最新;
4. **一期不做参数 Redis 缓存**(算钱系统缓存失效即错钱;20 行级查询成本可忽略),保留 `ys:param:ver:*` 键位待实测后启用;
5. 维度覆盖链:**角色维度 > 产品类别维度 > 默认行**;`dimension_value` 用空串不用 NULL(MySQL 组合索引对 NULL 不去重);
6. append-only 表(`param_values`/`param_change_batches`)**不继承 TimestampMixin**(无 update_time,与 `login_logs` 先例一致)。
### 2.2 表设计(V3 迁移,四张新表)
**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明;这是 #28「人员岗位角色」的落点)
| 列 | 类型 | 约束/说明 |
| --- | --- | --- |
| id | BIGINT UNSIGNED | PK AUTO_INCREMENT |
| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改(同 roles 保护) |
| role_name | VARCHAR(64) | NOT NULL |
| calc_template | VARCHAR(32) | NOT NULL,计提公式模板编码(以代码注册表为准) |
| description | VARCHAR(255) | NULL |
| sort_order | INT | NOT NULL DEFAULT 0 |
| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) |
| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) |
| create_time / update_time | DATETIME | TimestampMixin |
种子(迁移 + init-dev 双写):`salesperson 营销员`(TPL_SALESPERSON_DECAY)、`sales_manager 营销经理`(TPL_MANAGER_SHARE)、`info_officer 信息员`(TPL_INFO_SALES_FIXED),均内置。
**`param_definitions` 计提参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天,可 NULL);`dimension_type` VARCHAR(32) NOT NULL DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value` / `value_min` / `value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。
**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(如 `P20261012-01`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` BIGINT UNSIGNED FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT NOT NULL DEFAULT 0;仅 `create_time`。
**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`;`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64) NULL;`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。索引 `idx_param_values_param_id_dimension_value_effective_date`(解析热路径)。已生效条目禁改禁删;未生效批次可整批撤回(删批次+条目)。解析 SQL 留存 `db/queries/计提参数.sql`。
### 2.3 参数清单(16 项,迁移种子)
| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 依据 |
| --- | --- | --- | --- | --- | --- |
| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 需规 2.3.1(一)1 |
| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 对照表 #4 |
| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 对照表 #4 |
| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 对照表 #4 |
| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | #6/#15/#16 |
| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 同上 |
| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | 3 | #20 |
| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | #9 |
| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | #23 |
| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | #24 |
| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | #21 |
| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | #22 |
| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | #12、需规 2.1.2 |
| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | #8 |
| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | #17 |
| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 需规(归人员字段则撤,见开放问题) |
**不设参数**(作废条款的落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。
**种子生效日期**(迁移与 init-dev 双写):`product.threshold`/`salesperson.*` = 2026-02-17;`enterprise.rate` = **2026-02-17 起 2‰ + 2026-08-13 起 3‰ 两行**;`manager.share.*`/`info.rate` = 2026-08-13;支付环节/预付款/以物抵顶/月工资口径 = 2026-01-01。
### 2.4 公式模板与角色绑定
**模板注册表**(`app/services/calc_templates.py`,代码内有限集合):
| 模板编码 | 名称 | 形态 | 声明参数 |
| --- | --- | --- | --- |
| TPL_SALESPERSON_DECAY | 业务员个人户·天数线性递减 | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary |
| TPL_MANAGER_SHARE | 经理·团队与全公司分成 | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* |
| TPL_ENTERPRISE_FIXED | 企业户·固定计提 | 乘法:折算毛利润额 × 比例 | enterprise.rate |
| TPL_INFO_SALES_FIXED | 信息员·销售额固定比例 | 乘法:本人销售额 × 比例 | info.rate |
| TPL_PREPAY_FIXED | 预付款·固定计提 | 乘法:回款额 × 比例 | prepay.rate |
修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。
**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目。新增岗位 = 新增行 + 选模板(零代码,满足 #28 ①)。
**消费接口契约**(④ 与预览共用,函数签名级):
```python
# app/services/param_service.py
@dataclass(frozen=True, slots=True)
class ParamSnapshot:
biz_date: date
def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链解析后原始值
def fraction(self, code, **dims) -> Fraction: ...
def integer(self, code, **dims) -> int: ...
def boolean(self, code, **dims) -> bool: ...
def text(self, code, **dims) -> str: ...
def resolve_snapshot(session, biz_date: date) -> ParamSnapshot
def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut
def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut]
# app/services/formula.py(纯函数,预览与 ④ 共用;禁 float)
def payment_days(ship_date, receipt_date) -> int # 发货日首日=1
def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零
def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值)
def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3)
def apply_barter(amount, discount) -> Decimal
def payment_weight(r: int) -> Fraction # r×r
def start_payment_ok(received, revenue, ratio) -> bool
```
### 2.5 文件清单
#### 后端
| 层 | 文件 | 内容 |
| --- | --- | --- |
| models | `app/models/business_role.py`、`app/models/param.py`、`app/models/__init__.py`(改) | 四张表;**必须注册进 `__init__`**(漏注册会让 autogenerate 生成删表迁移) |
| repositories | `app/repositories/business_role_repository.py`、`param_repository.py` | 只 flush;`resolve_values(session, biz_date)` 一条 SQL 取全量快照;批次撤回用 `for_update` 锁行 |
| schemas | `app/schemas/business_role.py`、`param.py` | Out/CreateIn/UpdateIn/StatusIn/Query、TemplateOptionOut;ParamDefinitionOut/SnapshotOut/ChangeIn/Out/BatchOut/BatchDetailOut/DiffOut/PreviewIn/Out |
| services | `app/services/param_registry.py`、`calc_templates.py`、`formula.py`、`business_role_service.py`、`param_service.py` | 参数目录注册表(种子唯一来源 + 运行期校验);模板注册表;纯函数;岗位 CRUD+保护规则;参数提交/历史/对比/预览/导出 |
| api | `app/api/v1/system/business_roles.py`、`params.py`、`app/api/router.py`(改) | 挂载 `/api/v1/system/business-roles`、`/api/v1/system/params`(AdminUser) |
| core | `app/core/constants.py`(改) | `MODULE_BUSINESS_ROLE`/`MODULE_PARAM`、内置岗位编码、产品类别码 |
| 迁移 | `alembic/versions/xxxx_*.py`、`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql` | 四表 + 种子;双轨同步 |
| 依赖 | `backend/pyproject.toml`(改) | 增补 `openpyxl`(参数导出用;`uv add openpyxl`) |
**接口集合**:business-roles 列表/新增/详情/编辑/删除/启停 + `meta/templates` 模板下拉;params 目录快照(`asOf`)、单参数历史、变更批次提交/撤回/分页/明细、双日期对比、Excel 导出(`StreamingResponse`,非统一信封)、计算预览。写操作全走 `audit_service.audit()`;变更批次表与操作日志互为双保险。
#### 前端
| 类型 | 文件 | 说明 |
| --- | --- | --- |
| api | `src/api/businessRoles.ts`、`src/api/params.ts` | params 的导出接口直接用 axios 实例(响应为 blob,非统一信封,文件内注明例外) |
| 页面 | `src/views/system/business-roles/index.vue` | 列表+表单+启停+删除(保护提示)+模板选择(展示模板声明参数) |
| 页面 | `src/views/system/params/index.vue` | 按 param_group 分组;生效方式(立即/指定日期)+备注;保存二次确认;版本管理抽屉(记录/明细/对比/导出);预览抽屉 |
| 路由 | `src/router/index.ts`(改) | `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`、`{ title: '计提参数', …, order: 6 }` |
| 常量 | `src/utils/constants.ts`(改) | MODULE_OPTIONS 增补;参数分组/单位展示映射 |
| 复用 | `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` | 不新增组件 |
### 2.6 任务序列(步骤级)
任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。
---
#### 任务 T1:迁移与模型(四张新表 + 种子)
**文件:**
- 创建:`backend/app/models/business_role.py`、`backend/app/models/param.py`
- 修改:`backend/app/models/__init__.py`
- 创建:`backend/alembic/versions/xxxx_add_business_roles_and_params.py`(autogenerate 后人工补种子)
- 创建:`db/migration/V3__add_business_roles_and_params.sql`
- 修改:`db/init/init-dev.sql`
- 创建:`db/queries/计提参数.sql`
- [ ] **步骤 1:写两个模型文件**
按 2.2 字段设计实现 `BusinessRole`、`ParamDefinition`、`ParamChangeBatch`、`ParamValue`。写法参照 `backend/app/models/role.py`(`Mapped`/`mapped_column`、`mysql_table_args("中文表注释")`、`BIGINT_UNSIGNED`、`TINYINT_TYPE`);`ParamChangeBatch`/`ParamValue` 不继承 `TimestampMixin`,仅手写 `create_time`(`server_default=text("CURRENT_TIMESTAMP")`);外键目标类型必须与 `users.id` 一致(`BIGINT_UNSIGNED`)。
- [ ] **步骤 2:注册进 `models/__init__.py`**
四个类加入导入与 `__all__`(漏注册会让 autogenerate 把新表当成待删除)。
- [ ] **步骤 3:生成迁移并补种子**
```bash
cd backend && uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表"
```
人工检查:表/列中文 COMMENT、`uk_`/`idx_`/`fk_` 命名、`ON UPDATE CURRENT_TIMESTAMP` 子句;然后按 2.2/2.3 用 `op.bulk_insert` 或 `op.execute` 补种子:3 个内置岗位、16 项参数定义、版本条目(`enterprise.rate` 两行:2026-02-17=2、2026-08-13=3;其余按 2.3 生效日期表;维度行展开——`product.threshold` 两行市政/轨交、`salary.basic.mode` 两行业务员/经理)。种子条目的 `batch_id` 需要一条「初始版本」批次(`version_no` 如 `P20260101-00`、`remark` 初始版本)。
- [ ] **步骤 4:应用迁移并导出 SQL**
```bash
cd backend && uv run alembic upgrade head
uv run alembic upgrade <V2_revision>:head --sql > ../db/migration/V3__add_business_roles_and_params.sql
```
(V2 revision 从 `alembic/versions/` 中操作日志迁移文件读取。)
- [ ] **步骤 5:同步 `db/init/init-dev.sql`**
追加四表 DDL(与迁移产出一致)+ 种子 INSERT + 更新末尾 `alembic_version` 为 V3 revision id。
- [ ] **步骤 6:写 `db/queries/计提参数.sql`**
留存解析查询(时点化取值:`WHERE effective_date <= :biz_date ORDER BY effective_date DESC, id DESC` 取每组最新)与对比查询。
- [ ] **步骤 7:验证**
```bash
cd backend && uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q
```
预期:全部 PASS(schema 双轨无漂移、新表规范断言通过)。
- [ ] **步骤 8:Commit**
```bash
git add backend/app/models backend/alembic db/init/init-dev.sql db/migration db/queries
git commit -m "feat(system): 新增岗位角色与计提参数四表及 V3 迁移"
```
---
#### 任务 T2:参数注册表 + 公式纯函数
**文件:**
- 创建:`backend/app/services/param_registry.py`、`backend/app/services/calc_templates.py`、`backend/app/services/formula.py`
- 修改:`backend/app/core/constants.py`(内置岗位编码、产品类别码常量)
- 测试:`backend/tests/unit/test_formula.py`、`backend/tests/unit/test_param_registry.py`
- [ ] **步骤 1:编写失败的测试(公式,算钱核心)**
代表性用例(全部精确分数断言,禁近似):
```python
def test_业务员360天_比例恰为6‰():
rate = salesperson_rate(360, start=Fraction(10), slope=Fraction(4, 360),
floor=Fraction(4), boundary_days=360)
assert rate == Fraction(6, 1000)
def test_业务员361天_固定下限4‰_分界线():
rate = salesperson_rate(361, start=Fraction(10), slope=Fraction(4, 360),
floor=Fraction(4), boundary_days=360)
assert rate == Fraction(4, 1000)
def test_业务员第1天_按公式为899比90000_精确分数():
rate = salesperson_rate(1, start=Fraction(10), slope=Fraction(4, 360),
floor=Fraction(4), boundary_days=360)
assert rate == Fraction(10) - Fraction(1, 90) # 899/90 ‰
def test_回款天数_发货日首日为第1天_12月15日到1月10日为27天():
assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27
def test_回款天数_同日回款为1天():
d = date(2026, 1, 1)
assert payment_days(d, d) == 1
def test_毛利润额为零_折算额与绩效均为零_不除零():
assert converted_profit(Decimal("0"), Fraction(0), Fraction(3, 10)) == Decimal("0")
def test_毛利润率低于阈值_折算为负_绩效负值不归零():
# 销售额 100、毛利润率 10%、阈值 30% → 折算 -20
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(3, 10)) == Decimal("-20")
assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12")
def test_支付权重_R为60时权重0点36():
assert payment_weight(60) == Fraction(36, 100)
def test_注册表_16项_编码唯一_必填字段非空():
assert len(PARAM_REGISTRY) == 16
assert len({s.code for s in PARAM_REGISTRY.values()}) == 16
# 每项 name/group/value_type 非空
def test_解析分数_4除以360_按Fraction不丢精度():
assert parse_fraction("4/360") == Fraction(1, 90)
def test_模板注册表_五个模板_声明参数均在注册表内():
# TEMPLATES 的 params 引用必须全部存在于 PARAM_REGISTRY(跨表一致性)
```
- [ ] **步骤 2:运行确认失败**
```bash
cd backend && uv run pytest tests/unit/test_formula.py tests/unit/test_param_registry.py -q
```
预期:FAIL(`ModuleNotFoundError` / `NameError`)。
- [ ] **步骤 3:实现三个 service 文件**
`formula.py`:8 个纯函数(签名见 2.4 契约)。要点:`salesperson_rate` 中 `days <= boundary_days` 用线性式、否则取 `floor`;`converted_profit` 用化简式 `revenue × (rate − threshold)`;`performance_amount` 为乘法;`payment_days` 为 `(receipt_date - ship_date).days + 1`。
`param_registry.py`:`ParamSpec` 冻结 dataclass + `PARAM_REGISTRY`(16 项,值与 2.3 种子一致)+ `get_spec`(未注册编码抛 500 级异常,属代码 bug)+ `parse_fraction`(`Fraction(text)`,捕获 `ZeroDivisionError`/`ValueError` → 400)+ `validate_and_normalize`(类型/取值域/枚举校验,返回规范串)。
`calc_templates.py`:`TemplateSpec` + `TEMPLATES`(5 个模板,见 2.4)+ `get_template`/`required_params`。
- [ ] **步骤 4:运行确认通过**
```bash
cd backend && uv run pytest tests/unit -q
```
预期:全部 PASS(含既有单元基线)。
- [ ] **步骤 5:Commit**
```bash
git add backend/app/services/formula.py backend/app/services/param_registry.py backend/app/services/calc_templates.py backend/app/core/constants.py backend/tests/unit/test_formula.py backend/tests/unit/test_param_registry.py
git commit -m "feat(system): 计提公式模板注册表与精确分数计算函数"
```
---
#### 任务 T3:参数仓储 + 取值服务(时点化解析)
**文件:**
- 创建:`backend/app/repositories/param_repository.py`
- 创建:`backend/app/services/param_service.py`(先实现 `ParamSnapshot`/`resolve_snapshot` 与覆盖链选择)
- 测试:`backend/tests/unit/test_param_snapshot.py`(覆盖链纯逻辑;真实 DB 解析在 T8 统一集成测试)
- [ ] **步骤 1:编写失败的测试(覆盖链与兜底)**
```python
def test_覆盖链_角色维度优先于产品类别与默认():
# rows: (code, dimension) 含 ('enterprise.rate', ''), ('enterprise.rate', 'municipal_water')
# 取 role_code=None, product_category='municipal_water' → 返回产品类别行
def test_未定义参数_按注册表默认值兜底():
# 某参数无任何版本条目 → snapshot.fraction(code) == 注册表 default_value 的 Fraction
def test_同日多次变更_取id最大的一条():
# 同日两行 → 取 id 大者
```
- [ ] **步骤 2:运行确认失败**(`uv run pytest tests/unit/test_param_snapshot.py -q`,预期 FAIL)
- [ ] **步骤 3:实现**
`param_repository.py`:`resolve_values(session, biz_date)` 一条 SQL 取全量快照(按 `(param_id, dimension_value)` 分组取 `effective_date DESC, id DESC` 首行;可用窗口函数或子查询,SQL 留存 `db/queries/计提参数.sql`);批次 CRUD 与 `for_update` 加锁函数(签名参照 2.5 与设计报告)。
`param_service.py`:`ParamSnapshot`(frozen dataclass,持有解析后 `dict[(code, dim)] → value` 与 `biz_date`)+ `resolve_snapshot(session, biz_date)`(读定义 + 读条目 + 合并注册表默认值);`get/fraction/integer/boolean/text` 五类取值(覆盖链:role_code 维度 > product_category 维度 > 默认行;`fraction` 走 `parse_fraction`)。
- [ ] **步骤 4:运行确认通过**(`uv run pytest tests/unit -q`,预期 PASS)
- [ ] **步骤 5:Commit**
```bash
git add backend/app/repositories/param_repository.py backend/app/services/param_service.py backend/tests/unit/test_param_snapshot.py
git commit -m "feat(system): 计提参数按业务日期时点化取值服务"
```
---
#### 任务 T4:岗位角色 CRUD 后端(含审计与保护规则)
**文件:**
- 创建:`backend/app/repositories/business_role_repository.py`、`backend/app/schemas/business_role.py`、`backend/app/services/business_role_service.py`、`backend/app/api/v1/system/business_roles.py`
- 修改:`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_BUSINESS_ROLE)
- [ ] **步骤 1:实现四层文件**
整体参照 `roles` 模块(`role_repository.py`/`role_service.py`/`api/v1/system/roles.py`)的写法与保护规则模式。接口:列表(分页+关键字+状态)、新增、详情、编辑、删除、启停、`GET /meta/templates` 模板下拉。保护规则:`is_builtin` 禁删禁停;`role_code` 创建后不可改;`calc_template` 必须存在于 `TEMPLATES`(否则 400);删除前检查人员引用(② 落地前以引用计数钩子预留,接入后生效);写操作全部包 `audit_service.audit()`。
- [ ] **步骤 2:冒烟验证(容器 + 后端在跑)**
```bash
cd backend && uv run uvicorn app.main:app --reload --port 8000
# 另一终端:curl 登录取 token 后依次打 7 个端点;重点验证:
# - 新增自定义岗位(绑 TPL_ENTERPRISE_FIXED)→ 201/200
# - 删除内置 salesperson → 403;删除自定义岗位 → 成功
# - calc_template 传不存在的编码 → 400
```
- [ ] **步骤 3:Commit**
```bash
git add backend/app/repositories/business_role_repository.py backend/app/schemas/business_role.py backend/app/services/business_role_service.py backend/app/api/v1/system/business_roles.py backend/app/api/router.py backend/app/core/constants.py
git commit -m "feat(system): 业务岗位角色管理接口与保护规则"
```
(模块级正式用例按项目约定在 T8 统一编写。)
---
#### 任务 T5:参数变更/历史/对比/导出/预览后端
**文件:**
- 创建:`backend/app/schemas/param.py`、`backend/app/api/v1/system/params.py`
- 修改:`backend/app/services/param_service.py`(提交/撤回/历史/对比/预览/导出)、`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_PARAM)、`backend/pyproject.toml`(`uv add openpyxl`)
- [ ] **步骤 1:加依赖**:`cd backend && uv add openpyxl`(参数导出用)
- [ ] **步骤 2:实现 schemas 与 service 扩展**
`submit_change`:事务内校验全部 items(`validate_and_normalize`)→ 建批次(`version_no` 规则 `P{YYYYMMDD}-{当日序号}`)→ 逐项写 `param_values`(`old_value_text` 取当前生效值)→ `item_count` → commit。`withdraw_batch`:已生效(`effective_date <= today`)禁撤(400);`for_update` 锁批次行后删条目与批次。`compare`:两日期各取快照后逐项 diff。`preview`:入参(产品类别/客户类型/岗位/发货日/回款日/回款金额/销售额/毛利润额/是否以物抵顶/是否试用期)→ 输出(回款天数/计提比例/折算毛利润额/单笔绩效/起付判定/R×R 结果/所用参数生效版本)。`export_changes`:openpyxl 流式写变更记录(列:变更人/时间/参数/旧值/新值/生效日期/批次号)。
- [ ] **步骤 3:实现 api 层**
9 个端点(目录快照/历史/提交/撤回/批次分页/批次明细/对比/导出/预览);写操作包审计;导出返回 `StreamingResponse`(非统一信封,前端 api 层注明)。
- [ ] **步骤 4:冒烟验证**
```bash
# 提交一次变更(3 项参数,指定未来日期)→ 当前快照不变
# 到该日期(或提交立即生效)→ 快照变化、历史出现记录
# 撤回未生效批次 → 快照回退;撤回已生效批次 → 400
# 预览:360 天/361 天边界、12-15→01-10=27 天精确复现
# 导出 → xlsx 可打开且列齐全
```
- [ ] **步骤 5:Commit**
```bash
git add backend/app/schemas/param.py backend/app/services/param_service.py backend/app/api/v1/system/params.py backend/app/api/router.py backend/app/core/constants.py backend/pyproject.toml backend/uv.lock
git commit -m "feat(system): 计提参数版本管理、变更留痕与计算预览接口"
```
---
#### 任务 T6:前端岗位角色页
**文件:**
- 创建:`frontend/src/api/businessRoles.ts`、`frontend/src/views/system/business-roles/index.vue`
- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts`
- [ ] **步骤 1:api 封装 + 页面**
页面整体复用用户管理页模式(`usePagedList` + `useConfirmAction` + `AppPagination` + `StatusTag`);表单含模板下拉(选中后展示模板声明的参数清单与当前值);内置岗位删除/停用按钮禁用并给 title 提示;路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。
- [ ] **步骤 2:验证**
```bash
cd frontend && pnpm type-check && pnpm build
```
预期:类型检查与构建通过。
- [ ] **步骤 3:Commit**
```bash
git add frontend/src/api/businessRoles.ts frontend/src/views/system/business-roles frontend/src/router/index.ts frontend/src/utils/constants.ts
git commit -m "feat(system): 前端岗位角色管理页面"
```
---
#### 任务 T7:前端参数配置页(含版本管理抽屉与预览)
**文件:**
- 创建:`frontend/src/api/params.ts`、`frontend/src/views/system/params/index.vue`
- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts`
- [ ] **步骤 1:api 封装**(导出接口用 axios 实例取 blob,注明非统一信封例外)
- [ ] **步骤 2:页面实现**
按 `param_group` 分组展示参数(名称/当前值+单位/生效日期/口径说明);顶部「生效方式(立即/指定日期)+ 生效日期 + 备注」;保存前二次确认(列出改动项与旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」);路由 meta order 6。
- [ ] **步骤 3:验证**
```bash
cd frontend && pnpm type-check
# e2e(需容器+前后端在跑):改一个参数 → 变更记录出现 → 预览值变化 → 导出可下载
```
- [ ] **步骤 4:Commit**
```bash
git add frontend/src/api/params.ts frontend/src/views/system/params frontend/src/router/index.ts frontend/src/utils/constants.ts
git commit -m "feat(system): 前端计提参数配置与版本管理页面"
```
---
#### 任务 T8:模块测试统一编写与文档更新
**文件:**
- 创建:`backend/tests/unit/test_param_service.py`(可 fakeredis 化的部分)、`backend/tests/integration/test_business_roles.py`、`backend/tests/integration/test_param_management.py`
- 修改:`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`(接口清单)、`frontend/e2e/system_management.py` 或新增 `frontend/e2e/param_management.py`
- [ ] **步骤 1:编写集成用例**(覆盖审查重点 1/2/3)
参数精度专项(Fraction/1/90 全链路:注册表 → 种子 → 解析 → 预览一致);未来生效不泄漏;撤回回退;同日多次变更取最新;审计留痕(成功与失败);权限(普通用户 403);岗位保护规则(内置禁删、模板校验)。
- [ ] **步骤 2:全量回归**
```bash
cd backend && uv run pytest -q
```
预期:301 基线 + 新增用例全绿。
- [ ] **步骤 3:e2e 与文档**
前端 e2e 步骤(菜单出现两个新页、改参数→记录→预览链路);更新三份 README(用例清单/运行记录/发现缺陷/待补用例、接口清单)。
- [ ] **步骤 4:Commit**
```bash
git add backend/tests frontend/e2e backend/README.md frontend/README.md
git commit -m "test(system): 参数体系模块用例与测试记录"
```
---
### 2.7 风险与开放问题
#### 风险(已定对策)
1. 参数缓存:一期不做(见 2.1-4);若未来启用必须按批次版本号精确失效,禁通配删除;
2. 同日多次变更:UI 明示「同日多次变更按最新一条生效」;
3. `4/360` 展示:存储分数、展示格式化(预览页注明「精确值 4/360,非截断 0.011111」);
4. 模板扩展摩擦:新公式形态仍需加模板(一期接受,与「不引入规则引擎」一致);岗位表单把模板做成带说明的受控下拉;
5. 报表字段前移:预览出参尽量与需规 2.4.1 明细列同名,减少 ④ 映射工作;
6. 参数导出用 openpyxl:已在 T5 增补依赖(AI_CONTEXT 已认可方向)。
#### 开放问题(不阻塞首块开工,逐项有默认处理)
| # | 问题 | 默认处理 |
| --- | --- | --- |
| 1 | 第 1 天计提:需规图示「第 1 天 10‰」vs 公式 `10 − 4/360 ≈ 9.9889‰` | 按公式实现(上限为参考);起始值参数化,业务如需 10‰ 可在系统设置调整 |
| 2 | 企业户 02-17 前历史(2‰ 是否回溯) | 已按双种子(02-17=2‰、08-13=3‰)实现;如无需回溯可撤首行 |
| 3 | `payment.weight.mode` BALANCE 模式语义(余额口径) | 首块只存枚举;语义在 ④ 开工前确认 |
| 4 | `probation.salary.basic`(3000 元)是参数还是人员工资标准字段 | 暂列参数;② 开工时如归人员字段则移除 |
| 5 | 参数查询权限(普通用户可否只读快照) | 建议「查看可、修改仅管理员」,实现为 AdminUser 时可放宽——待确认 |
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
## 验证方式
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.