版本冻结 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 忽略,不进入版本库。
206 lines
8.0 KiB
Vue
206 lines
8.0 KiB
Vue
<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>
|