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
+7
View File
@@ -8,6 +8,13 @@ coverage/
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
.pnpm-store/
.workbuddy/
# 微信开发者工具在仓库根目录误生成的工程配置(本地产物,与 apps/miniprogram 下的同名文件重复)
/project.config.json
/project.private.config.json
# 微信开发者工具私人配置,含本机路径与调试开关,不入库
apps/miniprogram/project.private.config.json
apps/miniprogram/miniprogram_npm/ apps/miniprogram/miniprogram_npm/
apps/miniprogram/.miniprogram/ apps/miniprogram/.miniprogram/
apps/api/src/generated/prisma/ apps/api/src/generated/prisma/
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lehuo/admin-web", "name": "@lehuo/admin-web",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+27 -104
View File
@@ -26,7 +26,9 @@ const errors = reactive<{ username?: string; password?: string }>({});
const submitting = ref(false); const submitting = ref(false);
const showPassword = ref(false); const showPassword = ref(false);
const message = ref(''); const message = ref('');
const activeView = ref< const activeView = ref<ViewKey>('dashboard');
type ViewKey =
| 'dashboard' | 'dashboard'
| 'applications' | 'applications'
| 'beneficiaries' | 'beneficiaries'
@@ -39,8 +41,23 @@ const activeView = ref<
| 'notifications' | 'notifications'
| 'statistics' | 'statistics'
| 'financial-disclosures' | 'financial-disclosures'
| 'old-items' | 'old-items';
>('dashboard');
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()); onMounted(() => auth.restore());
@@ -144,108 +161,14 @@ async function logout(): Promise<void> {
</div> </div>
<nav> <nav>
<button <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" type="button"
:aria-current="activeView === 'old-items' ? 'page' : undefined" :aria-current="activeView === item.key ? 'page' : undefined"
@click="activeView = 'old-items'" @click="activeView = item.key"
> >
<AdminIcon name="content" />旧物善用管理 <AdminIcon :name="item.icon" />{{ item.label }}
</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'"
>
活动与报名
</button> </button>
</nav> </nav>
<p class="phase-note">仅限授权人员访问,关键业务操作均记录审计。</p> <p class="phase-note">仅限授权人员访问,关键业务操作均记录审计。</p>
@@ -276,7 +199,7 @@ async function logout(): Promise<void> {
<FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" /> <FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" />
<OldItemsView v-else-if="activeView === 'old-items'" /> <OldItemsView v-else-if="activeView === 'old-items'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" /> <NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else /> <StatisticsView v-else-if="activeView === 'statistics'" />
</div> </div>
</div> </div>
</template> </template>
+25 -1
View File
@@ -25,6 +25,30 @@ defineProps<{ name: string }>();
v-else-if="name === 'content'" v-else-if="name === 'content'"
d="M4 3h16v18H4V3Zm3 4v2h10V7H7Zm0 4v2h10v-2H7Zm0 4v2h7v-2H7Z" 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> </svg>
</template> </template>
+98 -4
View File
@@ -19,6 +19,7 @@
--color-background: var(--primitive-gray-50); --color-background: var(--primitive-gray-50);
--color-border: var(--primitive-gray-200); --color-border: var(--primitive-gray-200);
--color-sidebar: var(--primitive-navy-900); --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%); --focus-ring: 0 0 0 3px rgb(255 107 53 / 28%);
color: var(--color-text); color: var(--color-text);
background: var(--color-background); background: var(--color-background);
@@ -694,13 +695,19 @@ input[aria-invalid='true'] {
} }
.admin-sidebar nav { .admin-sidebar nav {
margin-top: 28px; margin-top: 28px;
min-height: 0;
flex: 1 1 auto;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none;
}
.admin-sidebar nav::-webkit-scrollbar {
display: none;
} }
.nav-item { .nav-item {
display: flex; display: flex;
width: 100%; width: 100%;
min-height: 48px; min-height: 42px;
padding: 10px 14px; padding: 8px 14px;
border: 0; border: 0;
border-radius: 10px; border-radius: 10px;
color: #e8edf2; color: #e8edf2;
@@ -723,7 +730,7 @@ input[aria-invalid='true'] {
} }
.admin-sidebar nav { .admin-sidebar nav {
display: grid; display: grid;
gap: 6px; gap: 4px;
} }
.inline-actions { .inline-actions {
display: flex; display: flex;
@@ -735,6 +742,7 @@ input[aria-invalid='true'] {
} }
.phase-note { .phase-note {
margin-top: auto; margin-top: auto;
flex-shrink: 0;
padding: 14px; padding: 14px;
border-radius: 10px; border-radius: 10px;
color: #cbd3dc; color: #cbd3dc;
@@ -1016,6 +1024,31 @@ input[aria-invalid='true'] {
margin: 0 auto; margin: 0 auto;
padding: 28px; 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 { .page-header {
display: flex; display: flex;
margin-bottom: 22px; margin-bottom: 22px;
@@ -1316,6 +1349,38 @@ tbody tr:hover {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px; 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 { .summary-card {
display: grid; display: grid;
min-height: 126px; min-height: 126px;
@@ -1466,6 +1531,9 @@ button.summary-card:hover {
.summary-grid { .summary-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.dashboard-grid {
grid-template-columns: 1fr 1fr;
}
.dashboard-charts { .dashboard-charts {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1487,7 +1555,27 @@ button.summary-card:hover {
.brand-block { .brand-block {
min-height: 52px; 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 { .phase-note {
display: none; display: none;
} }
@@ -1497,6 +1585,9 @@ button.summary-card:hover {
.content-area { .content-area {
padding: 20px 16px; padding: 20px 16px;
} }
.workspace-view {
padding: 20px 16px;
}
.page-header { .page-header {
flex-direction: column; flex-direction: column;
} }
@@ -1519,6 +1610,9 @@ button.summary-card:hover {
.summary-grid { .summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dashboard-grid {
grid-template-columns: 1fr;
}
.quick-actions { .quick-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+4 -4
View File
@@ -1,16 +1,16 @@
{ {
"name": "@lehuo/api", "name": "@lehuo/api",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
"bootstrap:admin": "tsx src/scripts/bootstrap-admin.ts", "bootstrap:admin": "tsx --env-file-if-exists=../../.env src/scripts/bootstrap-admin.ts",
"build": "pnpm --filter @lehuo/contracts build && prisma generate && tsc -p tsconfig.build.json", "build": "pnpm --filter @lehuo/contracts build && prisma generate && tsc -p tsconfig.build.json",
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"dev": "pnpm --filter @lehuo/contracts build && tsx watch src/main.ts", "dev": "pnpm --filter @lehuo/contracts build && tsx watch --env-file-if-exists=../../.env src/main.ts",
"start": "node dist/main.js", "start": "node dist/main.js",
"test:integration": "pnpm --filter @lehuo/contracts build && tsx test/run-integration.ts", "test:integration": "pnpm --filter @lehuo/contracts build && tsx --env-file-if-exists=../../.env test/run-integration.ts",
"test:unit": "pnpm --filter @lehuo/contracts build && vitest run --config vitest.config.ts", "test:unit": "pnpm --filter @lehuo/contracts build && vitest run --config vitest.config.ts",
"typecheck": "prisma generate && tsc --noEmit" "typecheck": "prisma generate && tsc --noEmit"
}, },
+12 -1
View File
@@ -1,7 +1,18 @@
import 'dotenv/config'; import { config } from 'dotenv';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig, env } from 'prisma/config'; import { defineConfig, env } from 'prisma/config';
// Prisma CLI 的工作目录是 apps/api,仓库根 .env 不在 dotenv 的默认查找路径内。
// 显式加载根目录 .env,保证 generate / migrate 与 API 运行时读取同一份配置。
const rootEnvFile = resolve(process.cwd(), '../../.env');
if (existsSync(rootEnvFile)) {
config({ path: rootEnvFile });
} else {
config();
}
export default defineConfig({ export default defineConfig({
schema: 'prisma/schema.prisma', schema: 'prisma/schema.prisma',
migrations: { migrations: {
@@ -1,3 +1,5 @@
import { selectedTabIndex } from './tab-state';
Component({ Component({
data: { data: {
selected: 0, selected: 0,
@@ -10,16 +12,27 @@ Component({
}, },
lifetimes: { lifetimes: {
attached() { attached() {
const route = `/${getCurrentPages().at(-1)?.route ?? 'pages/index/index'}`; this.syncSelected();
const selected = this.data.tabs.findIndex((item) => item.pagePath === route); },
if (selected >= 0) this.setData({ selected }); },
pageLifetimes: {
show() {
this.syncSelected();
}, },
}, },
methods: { methods: {
syncSelected() {
const route = getCurrentPages().at(-1)?.route ?? 'pages/index/index';
const selected = selectedTabIndex(route, this.data.tabs);
if (selected !== null && selected !== this.data.selected) this.setData({ selected });
},
switchTab(event: WechatMiniprogram.BaseEvent) { switchTab(event: WechatMiniprogram.BaseEvent) {
const index = Number(event.currentTarget.dataset['index']); const index = Number(event.currentTarget.dataset['index']);
const tab = this.data.tabs[index]; const tab = this.data.tabs[index];
if (tab) void wx.switchTab({ url: tab.pagePath }); if (tab) {
this.setData({ selected: index });
void wx.switchTab({ url: tab.pagePath });
}
}, },
}, },
}); });
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { selectedTabIndex } from './tab-state';
const tabs = [
{ pagePath: '/pages/index/index' },
{ pagePath: '/pages/service-hub/service-hub' },
{ pagePath: '/pages/public-welfare/public-welfare' },
{ pagePath: '/pages/profile/profile' },
];
describe('selectedTabIndex', () => {
it('matches the current page route after a tab page becomes visible', () => {
expect(selectedTabIndex('pages/profile/profile', tabs)).toBe(3);
expect(selectedTabIndex('/pages/service-hub/service-hub', tabs)).toBe(1);
});
it('does not select a tab for a non-tab route', () => {
expect(selectedTabIndex('pages/application-form/application-form', tabs)).toBeNull();
});
});
@@ -0,0 +1,9 @@
export interface TabRoute {
pagePath: string;
}
export function selectedTabIndex(route: string, tabs: readonly TabRoute[]): number | null {
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
const index = tabs.findIndex((tab) => tab.pagePath === normalizedRoute);
return index >= 0 ? index : null;
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { APPLICATION_FORM_STEPS, createApplicationFormEntry } from './application-form-flow';
describe('application form flow', () => {
it('starts at the relationship step when a service category is preselected', () => {
expect(createApplicationFormEntry(true)).toEqual({
choosingCategory: false,
step: 1,
totalSteps: 4,
});
});
it('shows an unnumbered category chooser when no category is preselected', () => {
expect(createApplicationFormEntry(false)).toEqual({
choosingCategory: true,
step: 1,
totalSteps: 4,
});
});
it('renumbers the remaining application steps from one to four', () => {
expect(APPLICATION_FORM_STEPS).toEqual(['申请关系', '联系方式', '描述需求', '确认提交']);
});
});
@@ -0,0 +1,9 @@
export const APPLICATION_FORM_STEPS = ['申请关系', '联系方式', '描述需求', '确认提交'] as const;
export function createApplicationFormEntry(hasPreselectedCategory: boolean) {
return {
choosingCategory: !hasPreselectedCategory,
step: 1,
totalSteps: APPLICATION_FORM_STEPS.length,
};
}
@@ -10,6 +10,7 @@ import {
SERVICE_CATEGORIES, SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS, SERVICE_CATEGORY_LABELS,
} from '../../constants/business-options'; } from '../../constants/business-options';
import { createApplicationFormEntry } from './application-form-flow';
import { MiniProgramApiError } from '../../services/api-client'; import { MiniProgramApiError } from '../../services/api-client';
import { import {
@@ -18,7 +19,7 @@ import {
} from '../../services/service-applications'; } from '../../services/service-applications';
import { createClientRequestId } from '../../utils/request-id'; import { createClientRequestId } from '../../utils/request-id';
const TOTAL_STEPS = 5; const DEFAULT_FORM_ENTRY = createApplicationFormEntry(false);
const CATEGORY_IMAGES: Record<ServiceCategory, string> = { const CATEGORY_IMAGES: Record<ServiceCategory, string> = {
OLD_AGE: '/assets/images/brand-elder-care.jpg', OLD_AGE: '/assets/images/brand-elder-care.jpg',
@@ -54,8 +55,7 @@ function isApplyMode(value: string): value is ApplicationApplyMode {
Page({ Page({
data: { data: {
step: 1, ...DEFAULT_FORM_ENTRY,
totalSteps: TOTAL_STEPS,
loading: true, loading: true,
submitting: false, submitting: false,
errorMessage: '', errorMessage: '',
@@ -87,9 +87,11 @@ Page({
} }
if (options.category && isServiceCategory(options.category)) { if (options.category && isServiceCategory(options.category)) {
this.setData({ this.setData({
...createApplicationFormEntry(true),
serviceCategory: options.category, serviceCategory: options.category,
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[options.category], serviceCategoryLabel: SERVICE_CATEGORY_LABELS[options.category],
}); });
return;
} }
void this.loadCategories(); void this.loadCategories();
}, },
@@ -123,6 +125,8 @@ Page({
this.setData({ this.setData({
serviceCategory: code, serviceCategory: code,
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[code], serviceCategoryLabel: SERVICE_CATEGORY_LABELS[code],
choosingCategory: false,
step: 1,
clientRequestId: createClientRequestId(), clientRequestId: createClientRequestId(),
errorMessage: '', errorMessage: '',
}); });
@@ -176,7 +180,7 @@ Page({
this.setData({ errorMessage: message }); this.setData({ errorMessage: message });
return; return;
} }
const next = Math.min(TOTAL_STEPS, this.data.step + 1); const next = Math.min(this.data.totalSteps, this.data.step + 1);
this.setData({ this.setData({
step: next, step: next,
errorMessage: '', errorMessage: '',
@@ -234,14 +238,13 @@ Page({
}, },
validateStep(step: number): string { validateStep(step: number): string {
if (step === 1 && !this.data.serviceCategory) return '请选择需要的服务类型'; if (step === 1 && !this.data.applyMode) return '请选择您与服务对象的关系';
if (step === 2 && !this.data.applyMode) return '请选择您与服务对象的关系'; if (step === 2) {
if (step === 3) {
if (!this.data.beneficiaryName.trim()) return '请填写服务对象姓名或称呼'; if (!this.data.beneficiaryName.trim()) return '请填写服务对象姓名或称呼';
if (!/^1[3-9]\d{9}$/.test(this.data.contactMobile.trim())) return '请填写正确的手机号码'; if (!/^1[3-9]\d{9}$/.test(this.data.contactMobile.trim())) return '请填写正确的手机号码';
if (this.data.regionCode.trim().length < 2) return '请填写所在市、区或街道'; if (this.data.regionCode.trim().length < 2) return '请填写所在市、区或街道';
} }
if (step === 4 && this.data.description.trim().length < 10) { if (step === 3 && this.data.description.trim().length < 10) {
return '请至少用 10 个字描述目前的困难或希望得到的帮助'; return '请至少用 10 个字描述目前的困难或希望得到的帮助';
} }
return ''; return '';
@@ -1,25 +1,25 @@
<view class="page application-page"> <view class="page application-page">
<view class="progress-card" aria-label="申请步骤"> <view wx:if="{{!choosingCategory}}" class="progress-card" aria-label="申请步骤">
<view class="progress-meta"> <view class="progress-meta">
<text>第 {{step}} 步,共 {{totalSteps}} 步</text> <text>第 {{step}} 步,共 {{totalSteps}} 步</text>
<text>{{step === 1 ? '选择服务' : step === 2 ? '申请关系' : step === 3 ? '联系方式' : step === 4 ? '描述需求' : '确认提交'}}</text> <text>{{step === 1 ? '申请关系' : step === 2 ? '联系方式' : step === 3 ? '描述需求' : '确认提交'}}</text>
</view> </view>
<view class="progress-track"><view class="progress-value step-{{step}}"></view></view> <view class="progress-track"><view class="progress-value step-{{step}}"></view></view>
</view> </view>
<view wx:if="{{loading}}" class="form-card" aria-live="polite"> <view wx:if="{{choosingCategory && loading}}" class="form-card" aria-live="polite">
<view class="skeleton-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view> <view class="skeleton-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
<text class="state-text">正在读取服务类型…</text> <text class="state-text">正在读取服务类型…</text>
</view> </view>
<view wx:elif="{{loading === false && categories.length === 0}}" class="state-card"> <view wx:elif="{{choosingCategory && loading === false && categories.length === 0}}" class="state-card">
<text class="state-title">暂时无法读取服务类型</text> <text class="state-title">暂时无法读取服务类型</text>
<text class="state-text">{{errorMessage}}</text> <text class="state-text">{{errorMessage}}</text>
<button class="secondary-button" bindtap="retryLoad">重新加载</button> <button class="secondary-button" bindtap="retryLoad">重新加载</button>
</view> </view>
<block wx:else> <block wx:else>
<view wx:if="{{step === 1}}" class="form-card"> <view wx:if="{{choosingCategory}}" class="form-card">
<view class="application-banner"><text>请选择需要的服务类型</text><text>我们会认真了解并及时回应</text></view> <view class="application-banner"><text>请选择需要的服务类型</text><text>我们会认真了解并及时回应</text></view>
<button <button
wx:for="{{categories}}" wx:for="{{categories}}"
@@ -38,7 +38,7 @@
</button> </button>
</view> </view>
<view wx:elif="{{step === 2}}" class="form-card"> <view wx:elif="{{step === 1}}" class="form-card">
<text class="form-title">帮谁申请?</text> <text class="form-title">帮谁申请?</text>
<text class="form-hint">请选择您与服务对象的关系。</text> <text class="form-hint">请选择您与服务对象的关系。</text>
<button <button
@@ -53,7 +53,7 @@
</button> </button>
</view> </view>
<view wx:elif="{{step === 3}}" class="form-card"> <view wx:elif="{{step === 2}}" class="form-card">
<text class="form-title">留下联系方式</text> <text class="form-title">留下联系方式</text>
<text class="form-hint">只填写联系服务所必需的信息,不需要精确门牌地址。</text> <text class="form-hint">只填写联系服务所必需的信息,不需要精确门牌地址。</text>
<text class="field-label">服务对象姓名或称呼 *</text> <text class="field-label">服务对象姓名或称呼 *</text>
@@ -66,7 +66,7 @@
<input class="field-input" value="{{preferredContactTime}}" maxlength="100" placeholder="例如:工作日上午" bindinput="updatePreferredContactTime" /> <input class="field-input" value="{{preferredContactTime}}" maxlength="100" placeholder="例如:工作日上午" bindinput="updatePreferredContactTime" />
</view> </view>
<view wx:elif="{{step === 4}}" class="form-card"> <view wx:elif="{{step === 3}}" class="form-card">
<text class="form-title">简单描述需要的帮助</text> <text class="form-title">简单描述需要的帮助</text>
<text class="form-hint">请勿填写身份证号码、银行卡号或精确门牌地址。</text> <text class="form-hint">请勿填写身份证号码、银行卡号或精确门牌地址。</text>
<textarea class="field-textarea" value="{{description}}" maxlength="1000" placeholder="请至少用 10 个字描述目前遇到的困难或希望得到的帮助" bindinput="updateDescription"></textarea> <textarea class="field-textarea" value="{{description}}" maxlength="1000" placeholder="请至少用 10 个字描述目前遇到的困难或希望得到的帮助" bindinput="updateDescription"></textarea>
@@ -102,7 +102,7 @@
</view> </view>
<text wx:if="{{errorMessage}}" class="inline-error" aria-live="assertive">{{errorMessage}}</text> <text wx:if="{{errorMessage}}" class="inline-error" aria-live="assertive">{{errorMessage}}</text>
<view class="form-actions"> <view wx:if="{{!choosingCategory}}" class="form-actions">
<button wx:if="{{step > 1}}" class="secondary-button action-button" disabled="{{submitting}}" bindtap="previousStep">上一步</button> <button wx:if="{{step > 1}}" class="secondary-button action-button" disabled="{{submitting}}" bindtap="previousStep">上一步</button>
<button wx:if="{{step < totalSteps}}" class="primary-button action-button" bindtap="nextStep">下一步</button> <button wx:if="{{step < totalSteps}}" class="primary-button action-button" bindtap="nextStep">下一步</button>
<button wx:else class="primary-button action-button" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitApplication">{{submitting ? '正在提交…' : '提交帮扶需求'}}</button> <button wx:else class="primary-button action-button" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitApplication">{{submitting ? '正在提交…' : '提交帮扶需求'}}</button>
@@ -28,18 +28,15 @@
} }
.step-1 { .step-1 {
width: 20%; width: 25%;
} }
.step-2 { .step-2 {
width: 40%; width: 50%;
} }
.step-3 { .step-3 {
width: 60%; width: 75%;
} }
.step-4 { .step-4 {
width: 80%;
}
.step-5 {
width: 100%; width: 100%;
} }
@@ -4,5 +4,7 @@
<block wx:if="{{state === 'loading'}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block> <block wx:if="{{state === 'loading'}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block>
<view wx:elif="{{state === 'error'}}" class="state-card"><text class="state-title">任务加载失败</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view> <view wx:elif="{{state === 'error'}}" class="state-card"><text class="state-title">任务加载失败</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
<view wx:elif="{{state === 'empty'}}" class="state-card"><text class="state-title">暂无协作任务</text><text class="state-text">当工作人员将服务工单指派给您后,会显示在这里。</text></view> <view wx:elif="{{state === 'empty'}}" class="state-card"><text class="state-title">暂无协作任务</text><text class="state-text">当工作人员将服务工单指派给您后,会显示在这里。</text></view>
<button wx:else wx:for="{{items}}" wx:key="publicId" class="case-card" data-id="{{item.publicId}}" bindtap="openCase"><view class="case-top"><text class="case-no">{{item.caseNo}}</text><text class="status-pill">{{item.statusLabel}}</text></view><text class="case-name">{{item.beneficiaryDisplayName}}</text><text class="case-meta">{{item.priorityLabel}}优先级 · 负责人 {{item.ownerDisplayName}}</text><text class="case-link">查看服务任务 ›</text></button> <block wx:else>
<button wx:for="{{items}}" wx:key="publicId" class="case-card" data-id="{{item.publicId}}" bindtap="openCase"><view class="case-top"><text class="case-no">{{item.caseNo}}</text><text class="status-pill">{{item.statusLabel}}</text></view><text class="case-name">{{item.beneficiaryDisplayName}}</text><text class="case-meta">{{item.priorityLabel}}优先级 · 负责人 {{item.ownerDisplayName}}</text><text class="case-link">查看服务任务 ›</text></button>
</block>
</view> </view>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lehuo/miniprogram", "name": "@lehuo/miniprogram",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@lehuo/contracts": "workspace:*" "@lehuo/contracts": "workspace:*"
+27 -3
View File
@@ -1,5 +1,5 @@
{ {
"appid": "touristappid", "appid": "wx6cfca7c83002d678",
"compileType": "miniprogram", "compileType": "miniprogram",
"libVersion": "trial", "libVersion": "trial",
"miniprogramRoot": "miniprogram/", "miniprogramRoot": "miniprogram/",
@@ -8,6 +8,30 @@
"es6": true, "es6": true,
"minified": true, "minified": true,
"postcss": true, "postcss": true,
"useCompilerPlugins": ["typescript"] "useCompilerPlugins": [
} "typescript"
],
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"enhance": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"condition": false,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
} }
+8
View File
@@ -32,6 +32,14 @@ for (const page of app.pages) {
for (const match of wxml.matchAll(/<\/?([A-Za-z][A-Za-z0-9-]*)/g)) { for (const match of wxml.matchAll(/<\/?([A-Za-z][A-Za-z0-9-]*)/g)) {
if (!allowedTags.has(match[1])) failures.push(`${page}.wxml 使用不支持标签 <${match[1]}>`); if (!allowedTags.has(match[1])) failures.push(`${page}.wxml 使用不支持标签 <${match[1]}>`);
} }
for (const match of wxml.matchAll(/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*)>/g)) {
const attributes = match[2];
if (/\bwx:(?:elif|else)\b/.test(attributes) && /\bwx:for\b/.test(attributes)) {
failures.push(
`${page}.wxml 的 <${match[1]}> 不能同时使用 wx:elif/wx:else 与 wx:for,请用条件 block 包裹循环`,
);
}
}
const source = readFileSync(`${base}.ts`, 'utf8'); const source = readFileSync(`${base}.ts`, 'utf8');
for (const match of source.matchAll( for (const match of source.matchAll(
/wx\.(?:navigateTo|redirectTo)\(\{\s*url:\s*[`'"]([^`'"]+)/g, /wx\.(?:navigateTo|redirectTo)\(\{\s*url:\s*[`'"]([^`'"]+)/g,
+118
View File
@@ -4,6 +4,124 @@
## 当前阶段 ## 当前阶段
2026-09-24
任务:冻结 v0.2.0 版本并推送至 GitHub(origin/main)。
完成:① 版本统一升位 `0.1.0` → `0.2.0`(根、`@lehuo/api`、`@lehuo/admin-web`、`@lehuo/miniprogram`、`@lehuo/contracts` 五个 package.json),并打附注标签 `v0.2.0`;② `.gitignore` 增加 `.pnpm-store/`、`.workbuddy/`、仓库根目录的 `project.config.json` / `project.private.config.json`(微信开发者工具在仓库根误生成的本地产物)与 `apps/miniprogram/project.private.config.json`(含本机路径与调试开关),避免本地产物与机器相关信息入库;③ 固化本轮全部代码变更(管理后台 UI 修复与滚动条隐藏、API 开发脚本 env 加载、Prisma 配置、小程序 tab-state 与申请表单流程及配套单测与页面门禁脚本);④ 对待提交内容做密钥扫描(私钥、token、数据库连接串、管理员口令等关键词),无命中。
范围:仅版本元数据、忽略规则与既有代码变更的固化,未改动业务逻辑、API 契约、数据库 schema 或鉴权方案;`pnpm-lock.yaml` 未因版本升位改写(lockfile 内不含 workspace 包 version 字段,`pnpm install --frozen-lockfile` 判定 Already up to date)。
验证:`pnpm lint` exit 0、`pnpm test:unit` 19 个用例 exit 0、`@lehuo/miniprogram` typecheck exit 0、`@lehuo/admin-web` typecheck 与 build exit 0、无头浏览器端到端登录复验通过。
事故与排除:冻结前门禁出现 `pnpm lint` 4386 条报错(`Unsafe call of a type that could not be resolved`)。根因不是代码缺陷,而是 `apps/api/src/generated/prisma` 生成目录残缺——API 开发进程(`tsx watch`)与并发执行的 `prisma generate`(由 typecheck 脚本链式触发)争抢同一输出目录,Windows 文件锁使生成在 34/56 个模型文件处中断,`models/User.ts` 等缺失导致 TS 全量类型解析失败。停止竞争进程后删除并干净重新生成(56/56 模型齐全),lint 立即恢复 exit 0。该目录已被 `.gitignore:20` 忽略,不进入版本库。
遗留(本次冻结不等于生产发布):`docs/14_RELEASE_CHECKLIST.md` 中 UAT 全项、E2E P0、真机测试、Ubuntu 26.04 主机预检、TLS 与离站备份均未验证,仍保持未勾选,未做假绿勾选。集成测试依赖 `docker compose --profile test up -d mysql-test`,本轮未执行。`prisma generate` 与 `pnpm build` 必须在不运行 API 开发服务的前提下执行,否则必因 Windows 文件锁产生残缺生成物。
---
2026-09-24
任务:管理后台界面(apps/admin-web)视觉与可用性巡检修复。
完成:① 补齐 OldItemsView 使用的缺失样式类 `.workspace-view` / `.view-header` / `.table-wrap`——此前三者在 styles.css 中零定义,页面无内边距导致内容被 240px 侧边栏遮挡(标题被裁掉一半),表格在窄屏无横向滚动;② 补齐 StatisticsView 使用的缺失样式类 `.dashboard-grid` / `.dashboard-card` / `.stat-row`——此前 8 个统计指标卡退化为裸文本堆叠;③ 在 `:root` 补定义 `--shadow-card`(此前财务编辑面板引用了未定义变量,阴影失效);④ 侧边栏 13 项导航由手写重复改为 `NAV_ITEMS` 数据驱动渲染,并按业务逻辑重排(工作概览 → 服务申请 → 服务工单 → 回访工作台 → 服务对象 → 志愿者审核 → 公益项目 → 活动与报名 → 内容发布 → 财务公开 → 通知投递 → 服务统计 → 旧物善用管理,旧物管理不再插队第一);⑤ AdminIcon 补充 coins/phone/volunteer/flag/calendar/recycle 图标,消除 4 个无图标项与 3 项共用 content 图标的语义错误,13 项图标齐全、文字左缘对齐;⑥ 侧边栏 nav 行高 48→42、gap 6→4 并允许 flex 收缩 + 细滚动条,phase-note 设 flex-shrink:0,消除 100vh 内溢出与末项被遮挡;⑦ ≤760px 断点下导航不再 `display:none`(此前移动端完全无法切换页面),改为横向滚动条,nav-item 设 white-space:nowrap、激活态改为底部指示条。
范围:仅前端样式与 App.vue 导航渲染方式,未改动任何视图的业务逻辑、API 调用、权限判断、路由结构或登录流程;StatisticsView 显式分支化(原为 v-else 兜底),行为等价。
验证:无头 Edge + CDP 对 13 个页面逐一真实点击截图取证(1440×900 与 720×900 两档),对比修复前后:统计页指标卡由裸文本变为 4×2 卡片网格、旧物页标题完整可见、移动端 navVisibleCount 由 0 恢复为 13、`--shadow-card` 由 undefined 变为已定义;`consoleErrors` 与 `networkFailures` 全程为空。`pnpm lint` exit 0、`pnpm --filter @lehuo/admin-web typecheck` exit 0、`pnpm test:unit` 13 用例 exit 0、`pnpm --filter @lehuo/admin-web build` exit 0。完整 `pnpm typecheck` 中 apps/api 的失败为运行中的 API 进程锁定 `src/generated/prisma/models`(Windows EPERM,既有约束,与本次前端改动无关)。
---
2026-09-24
任务:将本机工具链升级到 Node 24 与 pnpm 11,使仓库声明的 `engines` 与质量门禁命令真正可用。
完成:安装 Node 24.21.0 LTS 到 `C:\Users\13070\.workbuddy\binaries\node\versions\24.21.0`(与既有 Node 22 并存,未覆盖 hermes 与 WorkBuddy 托管运行时),在该目录内安装 pnpm 11.13.1(与仓库 `packageManager: pnpm@11.13.1` 一致),并把该目录前置到用户级 PATH,使新开终端 `node` 为 v24.21.0、`pnpm` 为 11.13.1。修复 `apps/api/prisma.config.ts`:Prisma CLI 工作目录为 `apps/api`,根目录 `.env` 不在 dotenv 默认查找路径内,改为显式加载 `../../.env`,使 `prisma generate` / `migrate` 与 API 运行时读取同一份配置。为 `apps/api` 的 `dev`、`bootstrap:admin`、`test:integration` 增加 `tsx --env-file-if-exists=../../.env`,消除「文档第 6/7 步在干净终端必然失败」的缺陷。
范围:只改本地工具链与开发脚本的环境加载方式,未改动技术栈、运行时依赖版本、数据库 schema、API 契约、权限或业务状态机;`pnpm-lock.yaml` 未被改写(`pnpm install --frozen-lockfile` 直接判定 Already up to date)。
验证:在 `Node v24.21.0 + pnpm 11.13.1` 下,`pnpm install --frozen-lockfile`、`pnpm lint`、`pnpm typecheck`、`pnpm test:unit`(19 个用例)、`pnpm build` 全部退出码 0;`prisma generate` 日志确认 `injected env (43) from ..\..\.env`。停止旧开发进程后 `pnpm build` 不再受文件锁干扰。以新工具链重启 API 后 `GET /health` 返回 200 且监听进程为 Node 24.21.0;无头浏览器真实登录复验通过(`POST /api/v1/admin/auth/login` 201、进入后台布局、13 个导航项、`consoleErrors` 与 `networkFailures` 均为空)。
遗留:`pnpm build` 与 `prisma generate` 在开发服务器运行时必然因 Windows 文件锁(`EPERM: rmdir src/generated/prisma/models`)失败,属既有运行约束,需先停止 API 再执行,未通过改脚本规避。集成测试仍依赖 `docker compose --profile test up -d mysql-test`(该容器当前为 Exited),本轮未执行。本机 `pnpm` 旧版本 9.15.9 仍位于 WorkBuddy 托管目录,仅在新会话 PATH 中被 Node 24 目录覆盖,未删除。生产仍被 staging、UAT、真实域名与微信配置阻断,工具链升级不等于可发布。
---
2026-09-24
任务:修复管理后台登录页「管理后台尚未配置服务地址」并完成首次管理员初始化。
根因:`apps/admin-web` 只有 `.env.example`,缺少 `.env.local`,`VITE_API_BASE_URL` 未定义,`src/api/api-client.ts` 与 `StatisticsView.vue` 读取该变量时直接抛出配置类错误;同时业务库 `lehuo` 中 `admin_credentials` 与 `users` 均为 0 行,说明首次管理员从未引导,任何账号密码都无法登录。
完成:新建 `apps/admin-web/.env.local`(`VITE_API_BASE_URL=http://localhost:3000`,与根 `.env` 的 `ADMIN_WEB_ORIGIN=http://localhost:5173` 配套,已被 `.gitignore` 的 `.env.*` 规则忽略)。以显式环境变量执行 `bootstrap:admin`,创建 `SUPER_ADMIN` 管理员(本地开发专用凭据,不入库文档、不提交仓库;生产仍须使用 `infra/ubuntu/scripts/bootstrap-admin.sh` 交互式隐藏输入)。
范围:仅补齐本地开发的环境配置与首次管理员数据,未修改技术栈、认证方案、数据库 schema、API 契约、权限或业务状态机;未用测试值冒充生产配置。
验证:Vite 自动加载新 env 后,`GET /src/api/api-client.ts` 的转换结果包含 `"VITE_API_BASE_URL": "http://localhost:3000"`。无头浏览器真实登录通过:登录卡片消失、进入 `.admin-layout`、13 个导航项、顶栏显示已安全登录,`consoleErrors` 与 `networkFailures` 均为空,`POST /api/v1/admin/auth/login` 返回 201 且 `GET /api/v1/admin/statistics/overview` 返回 200,证明 HttpOnly Cookie 会话在浏览器端真实生效。概览指标为 0 属业务库为空,非缺陷。数据库复核:`admin_credentials` 1 行、角色 `SUPER_ADMIN`、状态 `ACTIVE`。
遗留:本次未修复三处环境缺口,另行报告。①`tsx` 不会自动加载根 `.env`(已实测),而根 README 第 6/7 步直接写 `pnpm --filter @lehuo/api bootstrap:admin` 与 `dev`,实际必须显式注入环境变量或改用 `--env-file-if-exists`;②本机 PATH 上 `pnpm` 为 9.15.9、Node 为 v22.23.2,与仓库 `engines`(`pnpm >=11 <12`、`node >=24 <25`)不符,`pnpm <script>` 直接报 `ERR_PNPM_UNSUPPORTED_ENGINE`;③Vite 实际仅监听 `::1:5173`,浏览器须用 `http://localhost:5173`,用 `127.0.0.1` 既不监听也会因 Origin 不匹配被 `AdminOriginGuard` 拒绝。
---
2026-09-24
任务:启动本地完整程序,进入微信小程序验收测试。
完成:启动并确认 MySQL、Redis、MinIO 均为 healthy,确认 12 个 migration 无待部署项;以后台开发进程启动 API 与管理端,并启动已安装的微信开发者工具。管理端已在 Codex 浏览器面板排队打开。
验证:API `http://localhost:3000/health`、Swagger `http://localhost:3000/api/docs` 和管理端 `http://localhost:5173/` 均返回 HTTP 200。本轮仅执行环境启动,代码沿用上一任务已通过的全仓 Lint、类型检查、33 个单元测试、生产构建与 38 个集成测试结果。
遗留:微信开发者工具的 CLI 服务端口处于关闭状态,已直接启动工具且未修改安全设置;如工具未自动恢复项目,需要从最近项目打开或导入 `E:\develop\lehuo\apps\miniprogram` 后清缓存重新编译。
---
2026-09-24
任务:清理服务页与帮扶申请表单之间重复的服务类型选择页,并重排申请步骤编号。
完成:服务页六个服务类型按钮继续携带已选类型进入申请表单,表单识别预选类型后跳过原“选择服务”步骤和类型接口等待,直接显示原第 2 步“申请关系”。正式申请流程由 5 步调整为 4 步:申请关系、联系方式、描述需求、确认提交,进度条比例同步调整。对于“我需要帮助”“帮家人申请”等未预选类型入口,保留不计入步骤的服务类型选择页,选择后进入正式第 1 步,避免丢失服务类型必填信息。
验证:新增流程回归测试先因实现缺失失败,修复后小程序 8 个单元测试通过;全仓 Lint、类型检查、33 个单元测试和生产构建全部通过;小程序 28 个页面完整性与运行时依赖门禁通过;专用 MySQL 上的 38 个真实集成测试通过。
遗留:需在微信开发者工具清缓存后,分别验证服务类型按钮直达第 1 步,以及“我需要帮助/帮家人申请”先选类型再进入第 1 步的两条入口。
---
2026-09-24
任务:统一修复小程序自定义底部导航选中态及首页、个人中心微信登录失败。
完成:确认登录 502 的根因是微信开发者工具项目 AppID 与 API AppID 不一致;微信官方 `jscode2session` 使用后台凭据和无效测试 code 返回 40029,证明凭据有效且问题来自 code 所属 AppID。将根目录和小程序目录的本地项目配置统一到后台 AppID。自定义底部导航新增独立路由选中计算,在组件 attached、页面 show 和点击切换三个时点同步 selected,解决 Tab 实例恢复后图标不变色。
验证:修复前两个项目配置均与 API AppID 不匹配,修复后均匹配;导航路由回归测试先因实现缺失失败,修复后小程序 5 个单元测试通过。全仓 Lint、类型检查、28 个单元测试、生产构建以及专用 MySQL 上的 38 个真实集成测试全部通过;小程序 28 个页面完整性和运行时依赖门禁通过。真实登录仍需微信开发者工具重载项目配置后使用新的实时 code 验证。
遗留:微信开发者工具需关闭当前项目后重新打开 `apps/miniprogram`,清除缓存并重新编译,确保新 AppID 生效;此前旧 AppID 生成的 code 不能复用。
---
2026-09-24
任务:部署本地开发依赖与 API,修复微信开发者工具请求 `localhost:3000` 被拒绝。
完成:定位 Docker Desktop 实际安装目录并使用其 Docker 29.8.0 / Compose 5.5.1 CLI;生成 Git 忽略的本地 `.env` 和随机开发密钥,启动 MySQL 8.4、Redis 7.4、MinIO,执行全部 12 个 migration,在 MinIO 创建私有 `lehuo-private` bucket,并以隐藏后台进程启动 API。未将本地密钥、密码或运行日志提交到仓库。
验证:MySQL、Redis、MinIO 容器均为 healthy;`GET /health` 返回 200,公开项目、公开活动和服务分类接口均返回 200,原 `ERR_CONNECTION_REFUSED` 根因已消除。配置本地微信 AppID/AppSecret 并重启 API 后,使用无效测试 code 请求登录接口返回 502 而非“未配置”的 503,确认请求已进入微信 code 换取阶段。
遗留:仍需由微信开发者工具使用实时 `wx.login` code 完成端到端登录验证。API 当前为本地隐藏开发进程,不是生产服务。
---
2026-09-24
任务:修复微信开发者工具编译“我的协作任务”页面时的 `wx:else` 错误。
完成:将协作任务列表的条件分支改为外层 `wx:else` block 包裹内部 `wx:for` 按钮,避免条件指令与循环指令位于同一节点导致 WXML 编译器无法关联前序 `wx:if/wx:elif`。同步扩展小程序页面完整性检查,遇到 `wx:elif/wx:else` 与 `wx:for` 共用节点时提前失败并给出修复提示。
验证:新增门禁在修复前稳定报出 `pages/assigned-cases/assigned-cases.wxml` 的指令冲突,修复后 28 个小程序页面完整性检查通过;本次修改文件的 Prettier 检查、全仓 Lint、类型检查、28 个单元测试和生产构建通过。全仓 Prettier 检查仍被仓库现有 331 个文件的格式/行尾差异阻断;集成测试启动后因本机 `127.0.0.1:3307` 的 `lehuo_test` MySQL schema engine 不可用而未进入用例执行。
遗留:仍需在微信开发者工具清除缓存后重新编译,并人工确认协作任务的加载、失败、空状态和列表状态切换。
---
2026-08-15 2026-08-15
任务:将 Ubuntu 26.04 部署版本重新同步到 GitHub 仓库。 任务:将 Ubuntu 26.04 部署版本重新同步到 GitHub 仓库。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lehuo-public-service", "name": "lehuo-public-service",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"description": "乐活公益·暖心服务平台 V1.0 monorepo", "description": "乐活公益·暖心服务平台 V1.0 monorepo",
"license": "UNLICENSED", "license": "UNLICENSED",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lehuo/contracts", "name": "@lehuo/contracts",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",