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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lehuo/admin-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+27
-104
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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_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<ServiceCategory, string> = {
|
||||
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 '';
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<view class="page application-page">
|
||||
<view class="progress-card" aria-label="申请步骤">
|
||||
<view wx:if="{{!choosingCategory}}" class="progress-card" aria-label="申请步骤">
|
||||
<view class="progress-meta">
|
||||
<text>第 {{step}} 步,共 {{totalSteps}} 步</text>
|
||||
<text>{{step === 1 ? '选择服务' : step === 2 ? '申请关系' : step === 3 ? '联系方式' : step === 4 ? '描述需求' : '确认提交'}}</text>
|
||||
<text>{{step === 1 ? '申请关系' : step === 2 ? '联系方式' : step === 3 ? '描述需求' : '确认提交'}}</text>
|
||||
</view>
|
||||
<view class="progress-track"><view class="progress-value step-{{step}}"></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>
|
||||
<text class="state-text">正在读取服务类型…</text>
|
||||
</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-text">{{errorMessage}}</text>
|
||||
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
|
||||
</view>
|
||||
|
||||
<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>
|
||||
<button
|
||||
wx:for="{{categories}}"
|
||||
@@ -38,7 +38,7 @@
|
||||
</button>
|
||||
</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-hint">请选择您与服务对象的关系。</text>
|
||||
<button
|
||||
@@ -53,7 +53,7 @@
|
||||
</button>
|
||||
</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-hint">只填写联系服务所必需的信息,不需要精确门牌地址。</text>
|
||||
<text class="field-label">服务对象姓名或称呼 *</text>
|
||||
@@ -66,7 +66,7 @@
|
||||
<input class="field-input" value="{{preferredContactTime}}" maxlength="100" placeholder="例如:工作日上午" bindinput="updatePreferredContactTime" />
|
||||
</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-hint">请勿填写身份证号码、银行卡号或精确门牌地址。</text>
|
||||
<textarea class="field-textarea" value="{{description}}" maxlength="1000" placeholder="请至少用 10 个字描述目前遇到的困难或希望得到的帮助" bindinput="updateDescription"></textarea>
|
||||
@@ -102,7 +102,7 @@
|
||||
</view>
|
||||
|
||||
<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 < 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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
<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 === '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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lehuo/miniprogram",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@lehuo/contracts": "workspace:*"
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user