diff --git a/.gitignore b/.gitignore index c2171e6..86ce128 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,13 @@ coverage/ .DS_Store .idea/ .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/ apps/api/src/generated/prisma/ diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index a623bff..6ba3e73 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -1,6 +1,6 @@ { "name": "@lehuo/admin-web", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/apps/admin-web/src/App.vue b/apps/admin-web/src/App.vue index c476ce3..fa7513f 100644 --- a/apps/admin-web/src/App.vue +++ b/apps/admin-web/src/App.vue @@ -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('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 {

仅限授权人员访问,关键业务操作均记录审计。

@@ -276,7 +199,7 @@ async function logout(): Promise { - + diff --git a/apps/admin-web/src/components/AdminIcon.vue b/apps/admin-web/src/components/AdminIcon.vue index ef5bfc0..579e60f 100644 --- a/apps/admin-web/src/components/AdminIcon.vue +++ b/apps/admin-web/src/components/AdminIcon.vue @@ -25,6 +25,30 @@ defineProps<{ name: string }>(); v-else-if="name === 'content'" d="M4 3h16v18H4V3Zm3 4v2h10V7H7Zm0 4v2h10v-2H7Zm0 4v2h7v-2H7Z" /> - + + + + + + + diff --git a/apps/admin-web/src/styles.css b/apps/admin-web/src/styles.css index 57d5fa4..d9e163b 100644 --- a/apps/admin-web/src/styles.css +++ b/apps/admin-web/src/styles.css @@ -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; } diff --git a/apps/api/package.json b/apps/api/package.json index 599846d..c1f4c0c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,16 +1,16 @@ { "name": "@lehuo/api", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "commonjs", "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", "db:deploy": "prisma migrate deploy", "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", - "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", "typecheck": "prisma generate && tsc --noEmit" }, diff --git a/apps/api/prisma.config.ts b/apps/api/prisma.config.ts index a0d2fbc..1a8b9e9 100644 --- a/apps/api/prisma.config.ts +++ b/apps/api/prisma.config.ts @@ -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'; +// 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({ schema: 'prisma/schema.prisma', migrations: { diff --git a/apps/miniprogram/miniprogram/custom-tab-bar/index.ts b/apps/miniprogram/miniprogram/custom-tab-bar/index.ts index a46d07a..9c63ef2 100644 --- a/apps/miniprogram/miniprogram/custom-tab-bar/index.ts +++ b/apps/miniprogram/miniprogram/custom-tab-bar/index.ts @@ -1,3 +1,5 @@ +import { selectedTabIndex } from './tab-state'; + Component({ data: { selected: 0, @@ -10,16 +12,27 @@ Component({ }, lifetimes: { attached() { - const route = `/${getCurrentPages().at(-1)?.route ?? 'pages/index/index'}`; - const selected = this.data.tabs.findIndex((item) => item.pagePath === route); - if (selected >= 0) this.setData({ selected }); + this.syncSelected(); + }, + }, + pageLifetimes: { + show() { + this.syncSelected(); }, }, 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) { const index = Number(event.currentTarget.dataset['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 }); + } }, }, }); diff --git a/apps/miniprogram/miniprogram/custom-tab-bar/tab-state.spec.ts b/apps/miniprogram/miniprogram/custom-tab-bar/tab-state.spec.ts new file mode 100644 index 0000000..b174646 --- /dev/null +++ b/apps/miniprogram/miniprogram/custom-tab-bar/tab-state.spec.ts @@ -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(); + }); +}); diff --git a/apps/miniprogram/miniprogram/custom-tab-bar/tab-state.ts b/apps/miniprogram/miniprogram/custom-tab-bar/tab-state.ts new file mode 100644 index 0000000..4e4cefc --- /dev/null +++ b/apps/miniprogram/miniprogram/custom-tab-bar/tab-state.ts @@ -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; +} diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form-flow.spec.ts b/apps/miniprogram/miniprogram/pages/application-form/application-form-flow.spec.ts new file mode 100644 index 0000000..15eb8f2 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form-flow.spec.ts @@ -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(['申请关系', '联系方式', '描述需求', '确认提交']); + }); +}); diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form-flow.ts b/apps/miniprogram/miniprogram/pages/application-form/application-form-flow.ts new file mode 100644 index 0000000..76ffa51 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form-flow.ts @@ -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, + }; +} diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form.ts b/apps/miniprogram/miniprogram/pages/application-form/application-form.ts index 5cbfe73..f4e0f56 100644 --- a/apps/miniprogram/miniprogram/pages/application-form/application-form.ts +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form.ts @@ -10,6 +10,7 @@ import { SERVICE_CATEGORIES, SERVICE_CATEGORY_LABELS, } from '../../constants/business-options'; +import { createApplicationFormEntry } from './application-form-flow'; import { MiniProgramApiError } from '../../services/api-client'; import { @@ -18,7 +19,7 @@ import { } from '../../services/service-applications'; import { createClientRequestId } from '../../utils/request-id'; -const TOTAL_STEPS = 5; +const DEFAULT_FORM_ENTRY = createApplicationFormEntry(false); const CATEGORY_IMAGES: Record = { OLD_AGE: '/assets/images/brand-elder-care.jpg', @@ -54,8 +55,7 @@ function isApplyMode(value: string): value is ApplicationApplyMode { Page({ data: { - step: 1, - totalSteps: TOTAL_STEPS, + ...DEFAULT_FORM_ENTRY, loading: true, submitting: false, errorMessage: '', @@ -87,9 +87,11 @@ Page({ } if (options.category && isServiceCategory(options.category)) { this.setData({ + ...createApplicationFormEntry(true), serviceCategory: options.category, serviceCategoryLabel: SERVICE_CATEGORY_LABELS[options.category], }); + return; } void this.loadCategories(); }, @@ -123,6 +125,8 @@ Page({ this.setData({ serviceCategory: code, serviceCategoryLabel: SERVICE_CATEGORY_LABELS[code], + choosingCategory: false, + step: 1, clientRequestId: createClientRequestId(), errorMessage: '', }); @@ -176,7 +180,7 @@ Page({ this.setData({ errorMessage: message }); return; } - const next = Math.min(TOTAL_STEPS, this.data.step + 1); + const next = Math.min(this.data.totalSteps, this.data.step + 1); this.setData({ step: next, errorMessage: '', @@ -234,14 +238,13 @@ Page({ }, validateStep(step: number): string { - if (step === 1 && !this.data.serviceCategory) return '请选择需要的服务类型'; - if (step === 2 && !this.data.applyMode) return '请选择您与服务对象的关系'; - if (step === 3) { + if (step === 1 && !this.data.applyMode) return '请选择您与服务对象的关系'; + if (step === 2) { if (!this.data.beneficiaryName.trim()) return '请填写服务对象姓名或称呼'; if (!/^1[3-9]\d{9}$/.test(this.data.contactMobile.trim())) 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 ''; diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form.wxml b/apps/miniprogram/miniprogram/pages/application-form/application-form.wxml index 58e2bec..96995aa 100644 --- a/apps/miniprogram/miniprogram/pages/application-form/application-form.wxml +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form.wxml @@ -1,25 +1,25 @@ - + 第 {{step}} 步,共 {{totalSteps}} 步 - {{step === 1 ? '选择服务' : step === 2 ? '申请关系' : step === 3 ? '联系方式' : step === 4 ? '描述需求' : '确认提交'}} + {{step === 1 ? '申请关系' : step === 2 ? '联系方式' : step === 3 ? '描述需求' : '确认提交'}} - + 正在读取服务类型… - + 暂时无法读取服务类型 {{errorMessage}} - + 请选择需要的服务类型我们会认真了解并及时回应 diff --git a/apps/miniprogram/miniprogram/pages/application-form/application-form.wxss b/apps/miniprogram/miniprogram/pages/application-form/application-form.wxss index d36a975..6cfc88b 100644 --- a/apps/miniprogram/miniprogram/pages/application-form/application-form.wxss +++ b/apps/miniprogram/miniprogram/pages/application-form/application-form.wxss @@ -28,18 +28,15 @@ } .step-1 { - width: 20%; + width: 25%; } .step-2 { - width: 40%; + width: 50%; } .step-3 { - width: 60%; + width: 75%; } .step-4 { - width: 80%; -} -.step-5 { width: 100%; } diff --git a/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxml b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxml index a579731..e51050b 100644 --- a/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxml +++ b/apps/miniprogram/miniprogram/pages/assigned-cases/assigned-cases.wxml @@ -4,5 +4,7 @@ 任务加载失败{{message}} 暂无协作任务当工作人员将服务工单指派给您后,会显示在这里。 - + + + diff --git a/apps/miniprogram/package.json b/apps/miniprogram/package.json index 876b0fd..695877e 100644 --- a/apps/miniprogram/package.json +++ b/apps/miniprogram/package.json @@ -1,6 +1,6 @@ { "name": "@lehuo/miniprogram", - "version": "0.1.0", + "version": "0.2.0", "private": true, "dependencies": { "@lehuo/contracts": "workspace:*" diff --git a/apps/miniprogram/project.config.json b/apps/miniprogram/project.config.json index 4744080..d636e37 100644 --- a/apps/miniprogram/project.config.json +++ b/apps/miniprogram/project.config.json @@ -1,5 +1,5 @@ { - "appid": "touristappid", + "appid": "wx6cfca7c83002d678", "compileType": "miniprogram", "libVersion": "trial", "miniprogramRoot": "miniprogram/", @@ -8,6 +8,30 @@ "es6": true, "minified": 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": {} } diff --git a/apps/miniprogram/scripts/check-pages.mjs b/apps/miniprogram/scripts/check-pages.mjs index 297b48b..6adeba0 100644 --- a/apps/miniprogram/scripts/check-pages.mjs +++ b/apps/miniprogram/scripts/check-pages.mjs @@ -32,6 +32,14 @@ for (const page of app.pages) { 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]}>`); } + 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'); for (const match of source.matchAll( /wx\.(?:navigateTo|redirectTo)\(\{\s*url:\s*[`'"]([^`'"]+)/g, diff --git a/docs/10_TASK_STATE.md b/docs/10_TASK_STATE.md index cf2e311..1ed0d83 100644 --- a/docs/10_TASK_STATE.md +++ b/docs/10_TASK_STATE.md @@ -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