Files
form-center-front/src/views/AppContext.vue
T
chenxing 6401a69c18 t14:前端模块行退役——左导航三支路自解析、AppContext 重构为通用壳两视图
- App.vue:引导只剩 GetUserInfo,之后并行取 fillable/managed/capabilities 三支构造导航
  (取不到按空集/false 走,fail-closed,不造兜底菜单树);摘 GetUserMod 调用与 setModules
- store:modules/IModule 状态与 setModules 删除,改存三支路数据 + managesForm() 判"这张表我管不管得动"
- AppContext.vue:重写为通用壳两视图(§5.1)——管理员视图=数据列表+复制填报路径
  (添加/导入 T18 接进这一档),填报者视图=仅填报 tab;判定只看 managed 是否非空,
  壳零自有权限逻辑;GetUserMod?appCode= 拉目标应用树那一路与 :modules prop 链整条拆除
- Data.vue:导出按钮改"该表单 ALL 档(managed 成员)",中心与快应用两种上下文从此同一条判据;
  AnswerDetail.vue 的管理动作区同判据;utils/modulePerms.ts(canExport/hasPage)删除
- utils/menuNav.ts:buildNavTree 与"从树渲染菜单"三条规则删除,只留导航数据结构与
  高亮/面包屑/默认跳转;menuRoute 的 /f/{code} 形态识别按任务书保留(分享直链/历史链接)
- mock:baseapi 去 GetUserMod 与两棵模块树;dataapi 补 /my/managed 与 /my/capabilities 桩
2026-10-20 13:21:23 +08:00

323 lines
12 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">
/**
* 快应用上下文 `#/app/:appCode/` = <b>通用壳</b>(契约 v2 §5.1/D15,T14 重构)。
*
* 一个壳承载全部形态,**壳零自有权限逻辑**——两视图的判定只看后端两把清单:
* · `GET /api/form/my/managed?appCode=`(创建人 ∪ 表单管理员=该 app 域内的 ALL 档)
* · `GET /api/form/my/fillable?appCode=`(audience 门筛过的可填清单,入集即可填)
*
* 两视图(§5.1 原文):
* · <b>管理员视图</b>:数据列表 / 添加(代录,T18 接)/ 导入(T18 接)/ 删除 / 复制填报路径;
* · <b>填报者视图</b>:只有「填报」tab。
* 判定即"对这张 app 域表单是不是 ALL 档持有者"——原 P95 的 `myTier` 三档写死分组随三层模型出清
* (§19 作废-9),原 `GetUserMod?appCode=` 拉目标应用树那一路也整条摘掉(它唯一剩余用途=导出锚点判定,
* 现改 managed 成员判定,B13/P78 遗迹清零)。
*
* 仍保留的既有形态:只有一个可填项且地址没带子视图段时<b>单表单直落</b>(T8/P95 的入口语义,不叠导航)。
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { listFillableForms, listManagedForms } from "@/api/module/fill";
import { fillRouteOf } from "@/utils/fillLink";
import Data from "@/views/admin/Data.vue";
import type { FillableForm, ManagedForm } from "@/types/data";
import { Message } from "view-ui-plus";
const route = useRoute();
const router = useRouter();
const appCode = computed(() => String(route.params.appCode ?? ""));
/** 子视图段(/app/:appCode/:view):空=按视图默认档(管理员→数据,填报者→填报) */
const viewParam = computed(() => String(route.params.view ?? ""));
const fillRows = ref<FillableForm[]>([]);
const managedRows = ref<ManagedForm[]>([]);
const loading = ref(false);
const errorText = ref("");
/** ALL 档持有=该 app 域里有 managed 行——这是两视图唯一的分岔判据 */
const isAdminView = computed(() => managedRows.value.length > 0);
const fillCards = computed(() => fillRows.value);
const adminCodes = computed(() => managedRows.value.map((r) => r.code));
const activeView = computed<"data" | "fill" | "unknown">(() => {
const v = viewParam.value.trim();
if (v === "data") return isAdminView.value ? "data" : "unknown";
if (v === "fill") return "fill";
if (!v) return isAdminView.value ? "data" : "fill";
return "unknown";
});
/** 单表单直落:填报者视图下只有一个可填项且地址没带子视图段 */
const isSingleForm = computed(() => !isAdminView.value && !viewParam.value.trim() && fillCards.value.length === 1);
const currentTitle = computed(() => (activeView.value === "data" ? "数据查看" : "填报"));
async function loadAll() {
if (!appCode.value) {
errorText.value = "地址里缺少 appCode";
return;
}
loading.value = true;
errorText.value = "";
try {
const [fillable, managed] = await Promise.all([
listFillableForms(appCode.value),
listManagedForms(appCode.value)
]);
fillRows.value = fillable || [];
managedRows.value = managed || [];
} catch (e: any) {
fillRows.value = [];
managedRows.value = [];
errorText.value = e?.message || "该应用的清单取不到";
} finally {
loading.value = false;
}
}
/** tab 点击:空段=按视图默认档,其余段按名分派 */
function gotoView(seg: "" | "data" | "fill") {
router.push(seg ? `/app/${appCode.value}/${seg}` : `/app/${appCode.value}/`);
}
function goFill(row: FillableForm) {
router.push(fillRouteOf(row));
}
function fillPathText(row: FillableForm): string {
const target = fillRouteOf(row);
return `${location.origin}${location.pathname}#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`;
}
/** 复制填报路径(§5.1 管理员视图动作):单表单直落地址即分享单元(code 寻址 + 会话) */
async function copyFillPath(row: FillableForm) {
const text = fillPathText(row);
try {
await navigator.clipboard.writeText(text);
Message.success("填报路径已复制:" + text);
} catch {
// 非 https/无剪贴板权限时 clipboard 会拒——降级成把地址摊在提示里让人手抄,不静默失败
Message.warning(`当前环境不允许自动复制,请手动复制:${text}`);
}
}
onMounted(loadAll);
watch(appCode, () => loadAll());
</script>
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="app-head">
<span class="app-tag">快应用</span>
<code class="app-code">{{ appCode || "(空)" }}</code>
<span class="toolbar-note">
清单按目标应用取(managed + fillable 两支),视图分岔只看 ALL 档,壳不自行判权
</span>
<Button icon="md-refresh" size="small" :loading="loading" @click="loadAll">刷新</Button>
</div>
<Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert>
<!-- 两视图导航:管理员才有「数据查看」档;填报档两种视图都在 -->
<div class="light-nav">
<span class="nav-live">
<a v-if="isAdminView" :class="['nav-item', { active: activeView === 'data' }]" href="javascript:;"
@click="gotoView('data')">数据查看</a>
<a :class="['nav-item', { active: activeView === 'fill' }]" href="javascript:;"
@click="gotoView('fill')">填报({{ fillCards.length }})</a>
</span>
<span class="toolbar-note">
{{ isAdminView ? "你在该应用下有 ALL 档的表单(创建人或表单管理员)" : "你在这里只是填报人——没有可管理的数据" }}
</span>
</div>
<h5 class="section-title">{{ currentTitle }}</h5>
<!-- 管理员视图:数据列表(内嵌 Data,候选=managed 的 code)+ 复制填报路径。
「添加(代录)」与「导入」在 T18 接进这一档(同一条 submit 管线、同一 ALL 档守卫)。 -->
<template v-if="activeView === 'data'">
<div class="manage-rows">
<div v-for="row in managedRows" :key="row.code" class="manage-row">
<div class="manage-main">
<div class="manage-name">{{ row.name }}</div>
<code class="manage-path">{{ fillPathText(row) }}</code>
<span class="toolbar-note">
来源:{{ (row.role || []).map((r) => (r === 'creator' ? '创建人' : '表单管理员')).join('、') }}
</span>
</div>
<Button size="small" icon="md-copy" @click="copyFillPath(row)">复制填报路径</Button>
</div>
</div>
<Data v-if="adminCodes.length" context="app" :app-code="appCode" :codes="adminCodes" embedded />
</template>
<template v-else-if="activeView === 'fill'">
<!-- 单表单直落:只有一个可填项时不叠列表,内容区直接给这张表的填报入口 -->
<template v-if="isSingleForm">
<Alert show-icon type="info">
单表单直落:该应用在你名下只有一个可填入口,内容区直接给它,不再叠一层导航。
<br />填报目标:<code>{{ fillPathText(fillCards[0]) }}</code>
</Alert>
<div class="fill-hint">
直接进入填报页:
<a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a>
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
</div>
</template>
<div v-else-if="fillCards.length" class="fill-cards">
<div v-for="row in fillCards" :key="row.code" class="fill-card">
<div class="fill-card-main">
<div class="fill-card-name">{{ row.name }}</div>
<code class="fill-card-path">{{ fillPathText(row) }}</code>
</div>
<Button type="primary" @click="goFill(row)">进入填报</Button>
</div>
</div>
<Alert v-else show-icon type="warning" class="region-tip">
「{{ appCode }}」这个应用在你名下当前没有可填报的表单。
<br />表单发布后按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下,
面向教师的表出现在教师账号下)——与基础平台的模块行授权无关;匿名表单(ANONYMOUS)
不下发给登录会话,持 URL 直达即可。
</Alert>
</template>
<Alert v-else show-icon type="warning">
这一段路径对你不可见(本页只有 数据查看 / 填报 两档,前者要求该应用下有 ALL 档的表单)。
<br />能直接地址进来但看不到档,说明不是这张表的 ALL 档持有者——把关在后端守卫,不在这里。
</Alert>
<p class="toolbar-note foot-note">
V72 前端侧三半句:①导出/管理按钮按「该表单 ALL 档」显隐(内嵌 Data.vue 自己读 managed 清单,
宿主不再传权限树);②导航不再由模块树生成,按两支清单构造,path 空的权限锚点形态从此不存在;
③接口侧的 403 是另一半,<b>前端藏按钮/藏档不等于授权</b>。
</p>
</div>
</div>
</template>
<style scoped>
.app-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.app-tag {
display: inline-block;
padding: 1px 8px;
font-size: 12px;
color: #fff;
background: #2d8cf0;
border-radius: 3px;
}
.app-code {
color: #17233d;
}
.toolbar-note {
color: #808695;
font-size: 12px;
}
.region-tip {
margin-bottom: 12px;
}
.light-nav {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.nav-live {
display: inline-flex;
gap: 4px;
}
.nav-item {
display: inline-block;
padding: 3px 12px;
font-size: 13px;
color: #515a6e;
border-radius: 3px;
}
.nav-item:hover {
color: #2d8cf0;
background: #f0f6ff;
}
.nav-item.active {
color: #2d8cf0;
background: #f0f6ff;
font-weight: 600;
}
.section-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
color: #17233d;
border-left: 3px solid #2d8cf0;
padding-left: 8px;
}
.manage-rows {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.manage-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
border: 1px solid #e8eaec;
border-radius: 4px;
background: #fff;
}
.manage-name {
font-size: 13px;
font-weight: 600;
color: #17233d;
}
.manage-path {
display: inline-block;
margin: 2px 8px 2px 0;
color: #808695;
font-size: 12px;
}
.fill-hint {
padding: 12px 0;
font-size: 13px;
color: #515a6e;
}
.fill-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.fill-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border: 1px solid #e8eaec;
border-radius: 4px;
background: #fff;
}
.fill-card-name {
font-size: 14px;
color: #17233d;
font-weight: 600;
}
.fill-card-path {
color: #808695;
font-size: 12px;
margin-top: 4px;
display: inline-block;
}
.foot-note {
display: block;
margin-top: 20px;
line-height: 1.6;
}
</style>