Files
lehuo/apps/admin-web/src/App.vue
T
admin 7e71455c6b 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 忽略,不进入版本库。
2026-09-24 19:50:07 +08:00

206 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ApiClientError } from './api/api-client';
import { ADMIN_APP_TITLE } from './app-info';
import { validateLoginForm } from './login-form';
import { useAuthStore } from './stores/auth';
import ServiceApplicationsView from './views/ServiceApplicationsView.vue';
import BeneficiariesView from './views/BeneficiariesView.vue';
import ServiceCasesView from './views/ServiceCasesView.vue';
import FollowUpsView from './views/FollowUpsView.vue';
import VolunteersView from './views/VolunteersView.vue';
import ProjectsView from './views/ProjectsView.vue';
import ActivitiesView from './views/ActivitiesView.vue';
import ContentView from './views/ContentView.vue';
import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
import StatisticsView from './views/StatisticsView.vue';
import DashboardView from './views/DashboardView.vue';
import AdminIcon from './components/AdminIcon.vue';
import FinancialDisclosuresView from './views/FinancialDisclosuresView.vue';
import OldItemsView from './views/OldItemsView.vue';
const auth = useAuthStore();
const form = reactive({ username: '', password: '' });
const errors = reactive<{ username?: string; password?: string }>({});
const submitting = ref(false);
const showPassword = ref(false);
const message = ref('');
const activeView = ref<ViewKey>('dashboard');
type ViewKey =
| 'dashboard'
| 'applications'
| 'beneficiaries'
| 'cases'
| 'follow-ups'
| 'volunteers'
| 'projects'
| 'activities'
| 'content'
| 'notifications'
| 'statistics'
| 'financial-disclosures'
| '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());
async function submit(): Promise<void> {
message.value = '';
errors.username = undefined;
errors.password = undefined;
const validation = validateLoginForm(form.username, form.password);
Object.assign(errors, validation);
if (Object.keys(validation).length) return;
submitting.value = true;
try {
await auth.login(form.username.trim().toLowerCase(), form.password);
form.password = '';
} catch (error) {
message.value =
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '登录失败,请稍后重试';
} finally {
submitting.value = false;
}
}
async function logout(): Promise<void> {
submitting.value = true;
message.value = '';
try {
await auth.logout();
} catch (error) {
message.value = error instanceof Error ? error.message : '退出失败,请稍后重试';
} finally {
submitting.value = false;
}
}
</script>
<template>
<main v-if="auth.checking" class="app-shell app-shell--centered" aria-live="polite">
<section class="welcome-card">
<p class="eyebrow">乐活公益 · 暖心服务平台</p>
<h1>{{ ADMIN_APP_TITLE }}</h1>
<p>正在确认登录状态,请稍候…</p>
</section>
</main>
<main v-else-if="!auth.authenticated" class="app-shell app-shell--centered">
<section class="login-card" aria-labelledby="app-title">
<p class="eyebrow">乐活公益 · 暖心服务平台</p>
<h1 id="app-title">{{ ADMIN_APP_TITLE }}</h1>
<p class="intro">仅限经授权的工作人员使用。登录操作将记录安全审计。</p>
<form novalidate @submit.prevent="submit">
<label for="username">登录账号</label>
<input
id="username"
v-model="form.username"
name="username"
autocomplete="username"
:aria-invalid="Boolean(errors.username)"
:aria-describedby="errors.username ? 'username-error' : undefined"
/>
<p v-if="errors.username" id="username-error" class="field-error" role="alert">
{{ errors.username }}
</p>
<label for="password">登录密码</label>
<div class="password-field">
<input
id="password"
v-model="form.password"
name="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
:aria-invalid="Boolean(errors.password)"
:aria-describedby="errors.password ? 'password-error' : undefined"
/>
<button
type="button"
class="password-toggle"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
<p v-if="errors.password" id="password-error" class="field-error" role="alert">
{{ errors.password }}
</p>
<p v-if="message" class="form-message" role="alert">{{ message }}</p>
<button class="primary-button" type="submit" :disabled="submitting">
{{ submitting ? '正在安全登录…' : '登录管理后台' }}
</button>
</form>
</section>
</main>
<div v-else class="admin-layout">
<aside class="admin-sidebar" aria-label="管理后台导航">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true"></span>
<div><strong>乐活公益</strong><small>管理平台</small></div>
</div>
<nav>
<button
v-for="item in NAV_ITEMS"
:key="item.key"
:class="['nav-item', { 'nav-item--active': activeView === item.key }]"
type="button"
:aria-current="activeView === item.key ? 'page' : undefined"
@click="activeView = item.key"
>
<AdminIcon :name="item.icon" />{{ item.label }}
</button>
</nav>
<p class="phase-note">仅限授权人员访问,关键业务操作均记录审计。</p>
</aside>
<div class="admin-main">
<header class="admin-topbar">
<div>
<strong>{{ auth.user?.displayName ?? '工作人员' }}</strong>
<span>已安全登录</span>
</div>
<button class="text-button" type="button" :disabled="submitting" @click="logout">
{{ submitting ? '正在退出…' : '安全退出' }}
</button>
</header>
<p v-if="message" class="global-message" role="alert">{{ message }}</p>
<DashboardView
v-if="activeView === 'dashboard'"
@navigate="activeView = $event as typeof activeView"
/>
<ServiceApplicationsView v-else-if="activeView === 'applications'" />
<BeneficiariesView v-else-if="activeView === 'beneficiaries'" />
<ServiceCasesView v-else-if="activeView === 'cases'" />
<FollowUpsView v-else-if="activeView === 'follow-ups'" />
<VolunteersView v-else-if="activeView === 'volunteers'" />
<ProjectsView v-else-if="activeView === 'projects'" />
<ActivitiesView v-else-if="activeView === 'activities'" />
<ContentView v-else-if="activeView === 'content'" />
<FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" />
<OldItemsView v-else-if="activeView === 'old-items'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else-if="activeView === 'statistics'" />
</div>
</div>
</template>