chore(release): freeze v0.2.0

版本冻结 v0.2.0,固化 2026-09-24 本轮全部变更。

admin-web:
- 修复旧物善用管理页缺失样式类 .workspace-view / .view-header / .table-wrap,
  此前页面无内边距导致内容被侧边栏遮挡、窄屏表格无横向滚动
- 修复服务统计页缺失样式类 .dashboard-grid / .dashboard-card / .stat-row,
  此前 8 个指标卡退化为裸文本堆叠
- 补齐未定义的 --shadow-card 变量(财务编辑面板阴影此前失效)
- 侧边栏 13 项导航由手写重复改为 NAV_ITEMS 数据驱动,并按业务顺序重排
- AdminIcon 补齐 coins / phone / volunteer / flag / calendar / recycle 语义图标
- 侧边栏行高与间距压缩使 13 项一屏放下,滚动条隐藏
- 小于等于 760px 断点导航由 display:none 改为横向滚动,移动端恢复可切换

api:
- prisma.config.ts 显式加载仓库根 .env,修复 prisma generate / migrate 取不到 DATABASE_URL
- dev / bootstrap:admin / test:integration 增加 tsx --env-file-if-exists=../../.env

miniprogram:
- custom-tab-bar 选中态与申请表单分步流程抽为可测纯函数并补充单测
- 页面完整性门禁脚本增强

工程:
- 版本统一升位 0.1.0 到 0.2.0(根与 4 个 workspace 包)
- .gitignore 增加 .pnpm-store/、.workbuddy/、微信开发者工具私人配置
  与仓库根目录误生成的工程配置,避免本地产物与机器相关信息入库

验证:pnpm lint 通过、pnpm test:unit 19 个用例通过、miniprogram typecheck 通过、
admin-web typecheck 与 build 通过、pnpm install --frozen-lockfile 未改写 lockfile、
无头浏览器端到端登录复验通过、待提交内容密钥扫描无命中。

说明:本轮定位并排除了一起 Prisma 生成目录残缺事故——API 开发进程与并发的
prisma generate 争抢同一输出目录,Windows 文件锁导致只生成 34/56 个模型文件,
使 TS 全量类型解析失败并产生数千条 lint 报错;停止竞争进程后干净重新生成即恢复。
该生成目录已被 .gitignore 忽略,不进入版本库。
This commit is contained in:
2026-09-24 19:50:07 +08:00
parent 7aae913c9e
commit 7e71455c6b
22 changed files with 437 additions and 149 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lehuo/admin-web",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
+27 -104
View File
@@ -26,7 +26,9 @@ const errors = reactive<{ username?: string; password?: string }>({});
const submitting = ref(false);
const showPassword = ref(false);
const message = ref('');
const activeView = ref<
const activeView = ref<ViewKey>('dashboard');
type ViewKey =
| 'dashboard'
| 'applications'
| 'beneficiaries'
@@ -39,8 +41,23 @@ const activeView = ref<
| 'notifications'
| 'statistics'
| 'financial-disclosures'
| 'old-items'
>('dashboard');
| 'old-items';
const NAV_ITEMS: ReadonlyArray<{ key: ViewKey; label: string; icon: string }> = [
{ key: 'dashboard', label: '工作概览', icon: 'dashboard' },
{ key: 'applications', label: '服务申请', icon: 'application' },
{ key: 'cases', label: '服务工单', icon: 'case' },
{ key: 'follow-ups', label: '回访工作台', icon: 'phone' },
{ key: 'beneficiaries', label: '服务对象', icon: 'people' },
{ key: 'volunteers', label: '志愿者审核', icon: 'volunteer' },
{ key: 'projects', label: '公益项目', icon: 'flag' },
{ key: 'activities', label: '活动与报名', icon: 'calendar' },
{ key: 'content', label: '内容发布', icon: 'content' },
{ key: 'financial-disclosures', label: '财务公开', icon: 'coins' },
{ key: 'notifications', label: '通知投递', icon: 'notification' },
{ key: 'statistics', label: '服务统计', icon: 'chart' },
{ key: 'old-items', label: '旧物善用管理', icon: 'recycle' },
];
onMounted(() => auth.restore());
@@ -144,108 +161,14 @@ async function logout(): Promise<void> {
</div>
<nav>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'old-items' }]"
v-for="item in NAV_ITEMS"
:key="item.key"
:class="['nav-item', { 'nav-item--active': activeView === item.key }]"
type="button"
:aria-current="activeView === 'old-items' ? 'page' : undefined"
@click="activeView = 'old-items'"
:aria-current="activeView === item.key ? 'page' : undefined"
@click="activeView = item.key"
>
<AdminIcon name="content" />旧物善用管理
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'dashboard' }]"
type="button"
:aria-current="activeView === 'dashboard' ? 'page' : undefined"
@click="activeView = 'dashboard'"
>
<AdminIcon name="dashboard" />工作概览
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'applications' }]"
type="button"
:aria-current="activeView === 'applications' ? 'page' : undefined"
@click="activeView = 'applications'"
>
<AdminIcon name="application" />服务申请
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'statistics' }]"
type="button"
:aria-current="activeView === 'statistics' ? 'page' : undefined"
@click="activeView = 'statistics'"
>
<AdminIcon name="chart" />服务统计
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'content' }]"
type="button"
:aria-current="activeView === 'content' ? 'page' : undefined"
@click="activeView = 'content'"
>
<AdminIcon name="content" />内容发布
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'financial-disclosures' }]"
type="button"
:aria-current="activeView === 'financial-disclosures' ? 'page' : undefined"
@click="activeView = 'financial-disclosures'"
>
<AdminIcon name="content" />财务公开
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'notifications' }]"
type="button"
:aria-current="activeView === 'notifications' ? 'page' : undefined"
@click="activeView = 'notifications'"
>
<AdminIcon name="notification" />通知投递
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'beneficiaries' }]"
type="button"
:aria-current="activeView === 'beneficiaries' ? 'page' : undefined"
@click="activeView = 'beneficiaries'"
>
<AdminIcon name="people" />服务对象
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'cases' }]"
type="button"
:aria-current="activeView === 'cases' ? 'page' : undefined"
@click="activeView = 'cases'"
>
<AdminIcon name="case" />服务工单
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'follow-ups' }]"
type="button"
:aria-current="activeView === 'follow-ups' ? 'page' : undefined"
@click="activeView = 'follow-ups'"
>
回访工作台
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'volunteers' }]"
type="button"
:aria-current="activeView === 'volunteers' ? 'page' : undefined"
@click="activeView = 'volunteers'"
>
志愿者审核
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'projects' }]"
type="button"
:aria-current="activeView === 'projects' ? 'page' : undefined"
@click="activeView = 'projects'"
>
公益项目
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'activities' }]"
type="button"
:aria-current="activeView === 'activities' ? 'page' : undefined"
@click="activeView = 'activities'"
>
活动与报名
<AdminIcon :name="item.icon" />{{ item.label }}
</button>
</nav>
<p class="phase-note">仅限授权人员访问,关键业务操作均记录审计。</p>
@@ -276,7 +199,7 @@ async function logout(): Promise<void> {
<FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" />
<OldItemsView v-else-if="activeView === 'old-items'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else />
<StatisticsView v-else-if="activeView === 'statistics'" />
</div>
</div>
</template>
+25 -1
View File
@@ -25,6 +25,30 @@ defineProps<{ name: string }>();
v-else-if="name === 'content'"
d="M4 3h16v18H4V3Zm3 4v2h10V7H7Zm0 4v2h10v-2H7Zm0 4v2h7v-2H7Z"
/>
<path v-else d="M12 2a7 7 0 0 1 7 7v4l2 3v1H3v-1l2-3V9a7 7 0 0 1 7-7Zm-3 17h6a3 3 0 0 1-6 0Z" />
<path
v-else-if="name === 'coins'"
d="M12 4c4.4 0 8 1.1 8 2.5S16.4 9 12 9 4 7.9 4 6.5 7.6 4 12 4Zm8 5v2.5c0 1.4-3.6 2.5-8 2.5s-8-1.1-8-2.5V9c0 1.4 3.6 2.5 8 2.5S20 10.4 20 9Zm0 4.5V16c0 1.4-3.6 2.5-8 2.5S4 17.4 4 16v-2.5c0 1.4 3.6 2.5 8 2.5s8-1.1 8-2.5Z"
/>
<path
v-else-if="name === 'phone'"
d="M6.6 10.8c1.5 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.4.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.4 0 .7-.2 1l-2.3 2.2Z"
/>
<path
v-else-if="name === 'volunteer'"
d="M9 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-3.3 0-7 1.7-7 4v3h9.5a6 6 0 0 1-.5-2c0-1.9.9-3.6 2.3-4.7-1.3-.2-2.8-.3-4.3-.3Zm12-3.2-5.2 5.2-2-2L12.4 15.4 15.8 19l6.6-6.6-1.4-1.6Z"
/>
<path v-else-if="name === 'flag'" d="M5 3h2v18H5V3Zm4 1h10l-2.5 4L19 12H9V4Z" />
<path
v-else-if="name === 'calendar'"
d="M7 2h2v2h6V2h2v2h3v18H4V4h3V2ZM6 9v11h12V9H6Zm2 3h3v3H8v-3Z"
/>
<path
v-else-if="name === 'recycle'"
d="M7 7h8l-2.5-2.5L14 3l5 5-5 5-1.5-1.5L15 9H7a3 3 0 0 0-3 3H2a5 5 0 0 1 5-5Zm10 10H9l2.5 2.5L10 21l-5-5 5-5 1.5 1.5L9 15h8a3 3 0 0 0 3-3h2a5 5 0 0 1-5 5Z"
/>
<path
v-else
d="M12 2a7 7 0 0 1 7 7v4l2 3v1H3v-1l2-3V9a7 7 0 0 1 7-7Zm-3 17h6a3 3 0 0 1-6 0Z"
/>
</svg>
</template>
+98 -4
View File
@@ -19,6 +19,7 @@
--color-background: var(--primitive-gray-50);
--color-border: var(--primitive-gray-200);
--color-sidebar: var(--primitive-navy-900);
--shadow-card: 0 3px 12px rgb(0 0 0 / 4%);
--focus-ring: 0 0 0 3px rgb(255 107 53 / 28%);
color: var(--color-text);
background: var(--color-background);
@@ -694,13 +695,19 @@ input[aria-invalid='true'] {
}
.admin-sidebar nav {
margin-top: 28px;
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
scrollbar-width: none;
}
.admin-sidebar nav::-webkit-scrollbar {
display: none;
}
.nav-item {
display: flex;
width: 100%;
min-height: 48px;
padding: 10px 14px;
min-height: 42px;
padding: 8px 14px;
border: 0;
border-radius: 10px;
color: #e8edf2;
@@ -723,7 +730,7 @@ input[aria-invalid='true'] {
}
.admin-sidebar nav {
display: grid;
gap: 6px;
gap: 4px;
}
.inline-actions {
display: flex;
@@ -735,6 +742,7 @@ input[aria-invalid='true'] {
}
.phase-note {
margin-top: auto;
flex-shrink: 0;
padding: 14px;
border-radius: 10px;
color: #cbd3dc;
@@ -1016,6 +1024,31 @@ input[aria-invalid='true'] {
margin: 0 auto;
padding: 28px;
}
.workspace-view {
width: min(1440px, 100%);
margin: 0 auto;
padding: 28px;
}
.view-header {
display: flex;
margin-bottom: 22px;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.view-header h1 {
margin: 0;
font-size: 28px;
line-height: 1.3;
}
.view-header p {
margin: 8px 0 0;
color: var(--color-text-secondary);
line-height: 1.6;
}
.table-wrap {
overflow-x: auto;
}
.page-header {
display: flex;
margin-bottom: 22px;
@@ -1316,6 +1349,38 @@ tbody tr:hover {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.dashboard-grid {
display: grid;
margin-bottom: 20px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.dashboard-card {
display: grid;
padding: 18px;
gap: 6px;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.dashboard-card span {
color: var(--color-text-secondary);
}
.dashboard-card strong {
font-size: 32px;
font-variant-numeric: tabular-nums;
}
.stat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #ebecef;
}
.stat-row:last-of-type {
border-bottom: 0;
}
.summary-card {
display: grid;
min-height: 126px;
@@ -1466,6 +1531,9 @@ button.summary-card:hover {
.summary-grid {
grid-template-columns: 1fr 1fr;
}
.dashboard-grid {
grid-template-columns: 1fr 1fr;
}
.dashboard-charts {
grid-template-columns: 1fr;
}
@@ -1487,7 +1555,27 @@ button.summary-card:hover {
.brand-block {
min-height: 52px;
}
.admin-sidebar nav,
.admin-sidebar nav {
display: flex;
margin-top: 12px;
overflow-x: auto;
overflow-y: hidden;
gap: 8px;
padding-bottom: 4px;
scrollbar-width: none;
}
.admin-sidebar nav::-webkit-scrollbar {
display: none;
}
.nav-item {
width: auto;
flex: 0 0 auto;
min-height: 44px;
white-space: nowrap;
}
.nav-item--active {
box-shadow: inset 0 -3px var(--color-primary);
}
.phase-note {
display: none;
}
@@ -1497,6 +1585,9 @@ button.summary-card:hover {
.content-area {
padding: 20px 16px;
}
.workspace-view {
padding: 20px 16px;
}
.page-header {
flex-direction: column;
}
@@ -1519,6 +1610,9 @@ button.summary-card:hover {
.summary-grid {
grid-template-columns: 1fr;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.quick-actions {
grid-template-columns: 1fr;
}