- 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 桩
323 lines
12 KiB
Vue
323 lines
12 KiB
Vue
<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>
|