280 lines
9.9 KiB
Vue
280 lines
9.9 KiB
Vue
<template>
|
||
<div class="page-wrapper yh-fill">
|
||
<div class="main-container">
|
||
<div class="search-wrap fix">
|
||
<span class="tip">
|
||
应用管理(open_api_app):ANON=仅内部入口免签,仍须带 X-App-Key;appKey 填 <b>*</b> =
|
||
匿名窗口(无票据放行,≤31 天强制到期);改回 SIGNED 前先确认密钥链有该键。
|
||
</span>
|
||
<div class="fr">
|
||
<Button type="primary" @click="openCreate">新增应用</Button>
|
||
<Button class="ml8" @click="refresh">刷新</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :dataFilter="normalizeRows">
|
||
<template #appKey="{ row }">
|
||
<span v-if="row.appKey === '*'" class="window-tag">*(匿名窗口)</span>
|
||
<span v-else class="mono">{{ row.appKey }}</span>
|
||
</template>
|
||
<template #appName="{ row }">
|
||
<Input v-model="row.appName" size="small" placeholder="-" />
|
||
</template>
|
||
<template #authMode="{ row }">
|
||
<Select v-model="row.authMode" size="small" style="width: 110px"
|
||
@on-change="onModeChange(row)">
|
||
<Option value="SIGNED">SIGNED</Option>
|
||
<Option value="ANON">ANON</Option>
|
||
</Select>
|
||
</template>
|
||
<template #expireAt="{ row }">
|
||
<template v-if="row.authMode === 'ANON'">
|
||
<Input v-model="row.expireAtText" size="small" placeholder="yyyy-MM-dd HH:mm:ss"
|
||
style="width: 170px" :class="expireClass(row)" />
|
||
<div :class="expireClass(row)" class="expire-hint">{{ expireHint(row) }}</div>
|
||
</template>
|
||
<span v-else class="expire-none">-</span>
|
||
</template>
|
||
<template #enabled="{ row }">
|
||
<i-switch v-model="row.enabled" size="small" />
|
||
</template>
|
||
<template #remark="{ row }">
|
||
<Input v-model="row.remark" size="small" placeholder="-" />
|
||
</template>
|
||
<template #gmtModified="{ row }">
|
||
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
|
||
</template>
|
||
</TablePageAlovaNew>
|
||
|
||
<Modal v-model="createShow" :mask-closable="false" width="560" title="新增应用">
|
||
<Form :label-width="110" class="app-create-form">
|
||
<FormItem label="appKey:" required>
|
||
<Input v-model="createForm.appKey" placeholder="调用方 appKey(唯一;填 * = 匿名窗口行)" />
|
||
</FormItem>
|
||
<FormItem label="展示名:">
|
||
<Input v-model="createForm.appName" placeholder="可选" />
|
||
</FormItem>
|
||
<FormItem label="authMode:" required>
|
||
<Select v-model="createForm.authMode" style="width: 180px">
|
||
<Option value="SIGNED">SIGNED(验签,缺省)</Option>
|
||
<Option value="ANON">ANON(内部入口免签)</Option>
|
||
</Select>
|
||
</FormItem>
|
||
<FormItem v-if="createForm.authMode === 'ANON'" label="免签到期:" required>
|
||
<Input v-model="createForm.expireAt" placeholder="yyyy-MM-dd HH:mm:ss(必填,≤31 天)" />
|
||
</FormItem>
|
||
<FormItem label="备注:">
|
||
<Input v-model="createForm.remark" placeholder="可选" />
|
||
</FormItem>
|
||
<div class="form-tip" v-if="createForm.authMode === 'ANON'">
|
||
免签最长 31 天(每次保存重新起算),到期惰性恢复验签;ANON 应用不要求密钥链有键。
|
||
</div>
|
||
</Form>
|
||
<template #footer>
|
||
<Button @click="createShow = false">取消</Button>
|
||
<Button type="primary" :loading="creating" @click="submitCreate">确定</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { reactive, ref } from 'vue';
|
||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||
import { actionColumn } from '@/utils/tableActions';
|
||
import Utils from '@/utils';
|
||
|
||
defineOptions({ name: 'AppList' });
|
||
|
||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||
const savingId = ref<number | null>(null);
|
||
|
||
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
||
Apis.GatewayAdmin.apps({ params, cacheFor: null });
|
||
|
||
const columns = [
|
||
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
||
{ title: 'appKey', slot: 'appKey', width: 160 },
|
||
{ title: '展示名', slot: 'appName', width: 150 },
|
||
{ title: 'authMode', slot: 'authMode', width: 130 },
|
||
{ title: '免签到期', slot: 'expireAt', minWidth: 210 },
|
||
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
||
{ title: '备注', slot: 'remark', minWidth: 140 },
|
||
{ title: '更新时间', slot: 'gmtModified', width: 170 },
|
||
actionColumn(
|
||
[
|
||
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
|
||
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
|
||
],
|
||
{ mode: 'text', width: 90, fixed: 'right' },
|
||
),
|
||
];
|
||
|
||
/** 服务端 expireAt 为 ISO 串:补一行内编辑用的 "yyyy-MM-dd HH:mm:ss" 文本 */
|
||
function normalizeRows(rows: any[]) {
|
||
return (rows || []).map((r: any) => ({
|
||
...r,
|
||
expireAtText: r.expireAt ? Utils.dateFormat(r.expireAt, 'yyyy-MM-dd HH:mm:ss') : '',
|
||
}));
|
||
}
|
||
|
||
const DAY_MS = 24 * 3600 * 1000;
|
||
const MAX_TTL_DAYS = 31;
|
||
|
||
/** 到期可视化:已过期标红、临期(≤3 天)标橙 */
|
||
function expireState(row: any): 'expired' | 'soon' | 'ok' {
|
||
if (!row.expireAt) return 'expired';
|
||
const remain = new Date(String(row.expireAt).replace(/-/g, '/').replace(/T/, ' ')).getTime() - Date.now();
|
||
if (remain <= 0) return 'expired';
|
||
if (remain <= 3 * DAY_MS) return 'soon';
|
||
return 'ok';
|
||
}
|
||
|
||
function expireClass(row: any) {
|
||
const s = expireState(row);
|
||
return { 'expire-danger': s === 'expired', 'expire-warning': s === 'soon' };
|
||
}
|
||
|
||
function expireHint(row: any) {
|
||
const s = expireState(row);
|
||
if (s === 'expired') return '已过期(免签失效,等同无行)';
|
||
if (s === 'soon') return '临期,续期请重新保存';
|
||
return `有效期 ≤${MAX_TTL_DAYS} 天,续期重新起算`;
|
||
}
|
||
|
||
/** 切到 SIGNED 即视为放弃免签:清空行内到期文本(后端落库置 NULL) */
|
||
function onModeChange(row: any) {
|
||
if (row.authMode !== 'ANON') {
|
||
row.expireAtText = '';
|
||
}
|
||
}
|
||
|
||
function refresh() {
|
||
tableRef.value?.refreshData();
|
||
}
|
||
|
||
/** 行内编辑:只提交治理字段(appKey 是行身份,后端不接受改动);ANON 行连同到期一起提交 */
|
||
async function save(row: any) {
|
||
savingId.value = row.id;
|
||
try {
|
||
const data: Record<string, any> = {
|
||
appName: row.appName,
|
||
authMode: row.authMode,
|
||
enabled: row.enabled,
|
||
remark: row.remark,
|
||
};
|
||
if (row.authMode === 'ANON') {
|
||
data.expireAt = row.expireAtText;
|
||
}
|
||
const d: any = await Apis.GatewayAdmin.updateApp({
|
||
pathParams: { id: row.id },
|
||
data,
|
||
});
|
||
Message.success(`已保存,应用表加载 ${d?.apps ?? '-'} 条`);
|
||
} catch (e: any) {
|
||
// 错误提示由 alova 全局 onBizError 弹出,这里只做回滚,避免双 toast
|
||
tableRef.value?.refreshData();
|
||
} finally {
|
||
savingId.value = null;
|
||
}
|
||
}
|
||
|
||
/* 新增应用弹窗(O3:治理页闭环,不留"只有 UI 没有后端"断层) */
|
||
const createShow = ref(false);
|
||
const creating = ref(false);
|
||
const createForm = reactive({
|
||
appKey: '',
|
||
appName: '',
|
||
authMode: 'ANON',
|
||
expireAt: '',
|
||
remark: '',
|
||
});
|
||
|
||
function openCreate() {
|
||
createForm.appKey = '';
|
||
createForm.appName = '';
|
||
createForm.authMode = 'ANON';
|
||
createForm.expireAt = '';
|
||
createForm.remark = '';
|
||
createShow.value = true;
|
||
}
|
||
|
||
async function submitCreate() {
|
||
if (!createForm.appKey || !createForm.appKey.trim()) {
|
||
Message.warning('appKey 必填');
|
||
return;
|
||
}
|
||
if (createForm.authMode === 'ANON' && !createForm.expireAt.trim()) {
|
||
Message.warning('authMode=ANON 时免签到期必填(≤31 天)');
|
||
return;
|
||
}
|
||
creating.value = true;
|
||
try {
|
||
const d: any = await Apis.GatewayAdmin.createApp({ data: { ...createForm } });
|
||
Message.success(`已创建,应用表加载 ${d?.apps ?? '-'} 条`);
|
||
createShow.value = false;
|
||
tableRef.value?.reloadData('add' as any);
|
||
} catch (e: any) {
|
||
// 错误提示由 alova 全局 onBizError 弹出
|
||
} finally {
|
||
creating.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.search-wrap {
|
||
line-height: 32px;
|
||
|
||
.tip {
|
||
color: #848c98;
|
||
font-size: 13px;
|
||
|
||
b {
|
||
color: #ed4014;
|
||
}
|
||
}
|
||
|
||
.ml8 {
|
||
margin-left: 8px;
|
||
}
|
||
}
|
||
|
||
.mono {
|
||
font-family: Menlo, Consolas, monospace;
|
||
}
|
||
|
||
.window-tag {
|
||
color: #ff9900;
|
||
font-family: Menlo, Consolas, monospace;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.expire-hint {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
}
|
||
|
||
.expire-none {
|
||
color: #c5c8ce;
|
||
}
|
||
|
||
.expire-warning,
|
||
.expire-warning.expire-hint {
|
||
color: #ff9900;
|
||
}
|
||
|
||
.expire-danger,
|
||
.expire-danger.expire-hint {
|
||
color: #ed4014;
|
||
}
|
||
|
||
.form-tip {
|
||
margin: -6px 0 0 110px;
|
||
font-size: 12px;
|
||
color: #848c98;
|
||
line-height: 18px;
|
||
}
|
||
</style>
|