import { computed, reactive, ref } from "vue";
import { useQuasar } from "quasar";
import type { AdminMfaPayload, AuthApplicationPageItem } from "../../../models/AuthModels";
import { AdminApiService, type ApplicationFormPayload } from "../../../services/AdminApiService";
import { useAdminActions } from "./useAdminActions";
export interface ApplicationFormModel {
id: string;
clientId: string;
clientSecret: string;
displayName: string;
logo: string;
applicationType: string;
clientType: string;
redirectUrisText: string;
postLogoutRedirectUri: string;
permissions: string[];
customPermission: string;
}
const confidentialClientType = "confidential";
export function useApplicationForm(id?: string) {
const $q = useQuasar();
const api = new AdminApiService();
const { runWithState } = useAdminActions();
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const model = reactive<ApplicationFormModel>({
id: id ?? "",
clientId: "",
clientSecret: "",
displayName: "",
logo: "",
applicationType: "web",
clientType: confidentialClientType,
redirectUrisText: "",
postLogoutRedirectUri: "",
permissions: [
"ept:authorization",
"ept:token",
"gt:authorization_code",
"gt:refresh_token",
"rst:code",
"scp:openid",
"scp:profile",
],
customPermission: "",
});
const isEdit = computed(() => Boolean(id));
async function load() {
if (!id) {
return;
}
loading.value = true;
errorMessage.value = "";
try {
const result = await api.applicationDetail(id);
if (!result.success || !result.data) {
errorMessage.value = result.message ?? "加载应用失败";
return;
}
hydrate(result.data);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "加载应用失败";
} finally {
loading.value = false;
}
}
function hydrate(item: AuthApplicationPageItem) {
model.id = item.id;
model.clientId = item.clientId;
model.displayName = item.displayName ?? "";
model.logo = item.logo ?? "";
model.applicationType = item.applicationType ?? "web";
model.clientType = item.clientType ?? confidentialClientType;
model.redirectUrisText = item.redirectUris.join("\n");
model.postLogoutRedirectUri = item.postLogoutRedirectUri ?? "";
model.permissions = [...item.permissions];
}
function addCustomPermission() {
const value = model.customPermission.trim();
if (!value || model.permissions.includes(value)) {
model.customPermission = "";
return;
}
model.permissions.push(value);
model.customPermission = "";
}
function validate() {
if (!model.clientId.trim()) {
return "请填写 ClientId";
}
if (!model.displayName.trim()) {
return "请填写显示名称";
}
if (!model.clientType.trim()) {
return "请选择客户端类型";
}
if (
!isEdit.value &&
model.clientType === confidentialClientType &&
!model.clientSecret.trim()
) {
return "机密客户端需填写 ClientSecret";
}
return "";
}
async function save() {
const validationMessage = validate();
if (validationMessage) {
$q.notify({ type: "warning", message: validationMessage });
return false;
}
saving.value = true;
errorMessage.value = "";
try {
const payload = toPayload();
return await runWithState(
isEdit.value ? `update-application:${payload.id}` : "create-application",
(mfa) => {
const mfaPayload = { ...payload, mfa };
return isEdit.value
? api.updateApplication(mfaPayload)
: api.createApplication(mfaPayload);
},
"保存成功",
);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "保存失败";
$q.notify({ type: "negative", message: errorMessage.value });
return false;
} finally {
saving.value = false;
}
}
function toPayload(mfa?: AdminMfaPayload): ApplicationFormPayload {
return {
id: model.id,
clientId: model.clientId.trim(),
clientSecret: model.clientSecret.trim(),
displayName: model.displayName.trim(),
logo: model.logo.trim(),
applicationType: model.applicationType.trim(),
clientType: model.clientType.trim(),
redirectUris: parseLines(model.redirectUrisText),
postLogoutRedirectUri: model.postLogoutRedirectUri.trim(),
permissions: [...model.permissions],
mfa,
};
}
return {
model,
loading,
saving,
errorMessage,
isEdit,
load,
save,
addCustomPermission,
};
}
function parseLines(value: string) {
return value
.split(/[\n;]/)
.map((item) => item.trim())
.filter(Boolean);
}
这段代码是一个 Vue 3 组合式函数(composable) useApplicationForm,主要用于管理某个“应用”的创建和编辑表单。它结合了 Quasar UI 框架的提示和交互逻辑,也调用了后台 Admin API 服务。
下面我从整体到细节逐步解释。
1. 用途
这个 composable 在管理后台中用于处理“应用程序”的表单逻辑,比如:
- 新建应用时,默认给出一个空表单;
- 编辑已有应用时,通过
id加载已有数据并回填表单; - 对表单字段进行校验;
- 将表单数据组装成 API 需要的 payload;
- 创建或更新到后台,并在成功后给出提示。
2. 导入部分
import { computed, reactive, ref } from "vue";
import { useQuasar } from "quasar";
import type { AdminMfaPayload, AuthApplicationPageItem } from "../../../models/AuthModels";
import { AdminApiService, type ApplicationFormPayload } from "../../../services/AdminApiService";
import { useAdminActions } from "./useAdminActions";
- Vue 的响应式 API:
computed,reactive,ref。 useQuasar:获取 Quasar 的全局实例,用于弹出通知提示($q.notify)。AdminApiService:和后台 API 交互的服务类。useAdminActions:自定义组合函数,提供runWithState方法,用于处理与 MFA/操作状态有关的统一逻辑。- 类型定义:
AdminMfaPayload,AuthApplicationPageItem,ApplicationFormPayload。
3. 类型与常量
export interface ApplicationFormModel {
id: string;
clientId: string;
clientSecret: string;
displayName: string;
logo: string;
applicationType: string;
clientType: string;
redirectUrisText: string;
postLogoutRedirectUri: string;
permissions: string[];
customPermission: string;
}
ApplicationFormModel 描述了表单内部使用的中文/中间状态数据结构。注意:它不是直接对应 API 的 payload,而是为了在表单里维护 redirectUrisText(多行文本)和 customPermission 这种 UI 字段。
const confidentialClientType = "confidential";
定义了一个常量,表示“机密客户端”类型,后面用于条件校验以及默认值。
4. 组合式函数主体
export function useApplicationForm(id?: string) {
对外暴露一个函数 useApplicationForm,参数 id 是可选的。如果传入了 id,则认为是编辑模式;否则是新建模式。
4.1 初始化依赖
const $q = useQuasar();
const api = new AdminApiService();
const { runWithState } = useAdminActions();
$q用来调用 Quasar 的通知、确认框等 UI。api用来调用后台接口。runWithState是一个高阶方法,用来包装创建/更新请求,处理 MFA(多因素认证)验证和按钮 loading 状态。
4.2 响应式状态
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
三个 ref:
loading:是否正在加载已有应用详情。saving:是否正在保存(创建/更新)。errorMessage:错误消息文本,可展示在页面上。
4.3 模型数据
const model = reactive<ApplicationFormModel>({
id: id ?? "",
clientId: "",
clientSecret: "",
displayName: "",
logo: "",
applicationType: "web",
clientType: confidentialClientType,
redirectUrisText: "",
postLogoutRedirectUri: "",
permissions: [
"ept:authorization",
"ept:token",
"gt:authorization_code",
"gt:refresh_token",
"rst:code",
"scp:openid",
"scp:profile",
],
customPermission: "",
});
model 是一个 reactive 对象,初始值如下:
id默认为传入的id(或空字符串)。- 应用类型默认
web。 - 客户端类型默认
confidentialClientType。 permissions是一组默认授权范围。redirectUrisText为空,用于让用户粘贴多行重定向 URI。
这里用 reactive 是为了让表单数据双向绑定到模板更自然。
4.4 计算属性:是否编辑模式
const isEdit = computed(() => Boolean(id));
根据是否有 id 判断当前是编辑还是新建。
4.5 加载应用详情
async function load() {
if (!id) {
return;
}
loading.value = true;
errorMessage.value = "";
try {
const result = await api.applicationDetail(id);
if (!result.success || !result.data) {
errorMessage.value = result.message ?? "加载应用失败";
return;
}
hydrate(result.data);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "加载应用失败";
} finally {
loading.value = false;
}
}
- 没有
id直接返回,因为新建模式无需加载。 - 在
loading阶段清空错误。 - 调用后台接口
api.applicationDetail(id)。 - 如果接口返回
success: false,则显示错误信息。 - 如果成功,则调用
hydrate(result.data)把返回的数据写入model。 - 用
finally确保停止 loading。
4.6 从后台数据填充表单
function hydrate(item: AuthApplicationPageItem) {
model.id = item.id;
model.clientId = item.clientId;
model.displayName = item.displayName ?? "";
model.logo = item.logo ?? "";
model.applicationType = item.applicationType ?? "web";
model.clientType = item.clientType ?? confidentialClientType;
model.redirectUrisText = item.redirectUris.join("\n");
model.postLogoutRedirectUri = item.postLogoutRedirectUri ?? "";
model.permissions = [...item.permissions];
}
该方法把接口返回的 AuthApplicationPageItem 映射到 model:
- redirectUris 是一个字符串数组,变成用换行符连接的文本,方便在
<textarea>中编辑。 - 未提供字段时用空字符串或默认值兜底。
permissions用拷贝的方式展开,避免直接引用后台对象。
4.7 添加自定义权限
function addCustomPermission() {
const value = model.customPermission.trim();
if (!value || model.permissions.includes(value)) {
model.customPermission = "";
return;
}
model.permissions.push(value);
model.customPermission = "";
}
处理输入自定义权限的逻辑:
- 如果输入为空,或已存在于
permissions,则清空输入框但不重复添加。 - 否则加入数组,并清空输入框。
4.8 表单校验
function validate() {
if (!model.clientId.trim()) {
return "请填写 ClientId";
}
if (!model.displayName.trim()) {
return "请填写显示名称";
}
if (!model.clientType.trim()) {
return "请选择客户端类型";
}
if (
!isEdit.value &&
model.clientType === confidentialClientType &&
!model.clientSecret.trim()
) {
return "机密客户端需填写 ClientSecret";
}
return "";
}
按顺序校验关键字段:
clientId必填。displayName必填。clientType必填。- 新建模式下,如果选择了机密客户端类型且没有填写
ClientSecret,则提醒必须填写。编辑模式下,因为可以保持原 Secret 不变,所以不强制要求每次都填写。
如果校验失败,返回错误消息字符串;否则返回空字符串。
4.9 保存逻辑
async function save() {
const validationMessage = validate();
if (validationMessage) {
$q.notify({ type: "warning", message: validationMessage });
return false;
}
saving.value = true;
errorMessage.value = "";
try {
const payload = toPayload();
return await runWithState(
isEdit.value ? `update-application:${payload.id}` : "create-application",
(mfa) => {
const mfaPayload = { ...payload, mfa };
return isEdit.value
? api.updateApplication(mfaPayload)
: api.createApplication(mfaPayload);
},
"保存成功",
);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "保存失败";
$q.notify({ type: "negative", message: errorMessage.value });
return false;
} finally {
saving.value = false;
}
}
保存函数流程:
- 先调用
validate(),若出错则用 Quasar 通知展示 warning 提示,并返回false。 - 设置
saving为 true,清空错误。 - 调用
toPayload()生成 API 参数。 - 调用
runWithState来执行创建/更新:runWithState内部可能会弹出 MFA 验证对话框,并在验证通过后执行回调。- 它接收一个“操作键名”,例如
update-application:<id>或create-application,用于防止重复提交、管理 loading 等。 - 第二个参数是一个回调
(mfa) => { ... },因为 MFA 结果可能需要作为额外的 payload 字段传给后台,所以代码中把mfa合并进 payload。 - 第三个参数是成功后的提示文本。
- 如果成功,则返回
runWithState的结果(通常是true)。 - 如果抛异常,则记录错误并在页面上弹出 negative 提示。
- 最后
finally里恢复saving = false。
4.10 组装 payload
function toPayload(mfa?: AdminMfaPayload): ApplicationFormPayload {
return {
id: model.id,
clientId: model.clientId.trim(),
clientSecret: model.clientSecret.trim(),
displayName: model.displayName.trim(),
logo: model.logo.trim(),
applicationType: model.applicationType.trim(),
clientType: model.clientType.trim(),
redirectUris: parseLines(model.redirectUrisText),
postLogoutRedirectUri: model.postLogoutRedirectUri.trim(),
permissions: [...model.permissions],
mfa,
};
}
- 将表单数据转换为后端需要的
ApplicationFormPayload结构。 redirectUris从多行文本框内容解析成数组。mfa参数用于合并 MFA 验证结果。- 所有字符串字段都做了
.trim()去除首尾空格。
4.11 返回值
return {
model,
loading,
saving,
errorMessage,
isEdit,
load,
save,
addCustomPermission,
};
对外暴露:
model:表单所有字段。loading/saving/errorMessage:状态。isEdit:是否为编辑状态。load:加载详情的方法。save:保存方法。addCustomPermission:添加自定义权限方法。
使用时可在组件中这样调用:
const { model, loading, saving, isEdit, load, save, addCustomPermission } = useApplicationForm(props.id);
5. 辅助函数
function parseLines(value: string) {
return value
.split(/[\n;]/)
.map((item) => item.trim())
.filter(Boolean);
}
这是文件末尾的独立工具函数,作用是把“多行文本”或者“分号隔开”的字符串转换为字符串数组,丢弃空行、清理空格。例如输入:
https://a.com
https://b.com
会得到 ["https://a.com", "https://b.com"]。
6. 整体架构与注意事项
- 该 composable 把“创建”和“编辑”的差异隐藏在内部,对外接口基本统一。
- 编辑模式下
load()会自动填充表单;新建模式下不调用load()。 - MFA 验证流程被封装在
runWithState中,保存时通过回调参数获取 MFA 结果并合并到 payload,确保后台的 MFA 校验能通过。 - 校验逻辑是轻量级的,但关键字段都有校验,尤其对机密客户端和新建场景做了特殊要求,避免无 Secret 就保存。
model使用reactive,在模板中可以使用v-model直接绑定model.clientId、model.displayName等字段,非常方便。
总而言之,useApplicationForm 是管理后台应用管理模块中一个非常核心可复用的逻辑层,负责从表单展示、数据加载、校验、保存到错误提示等完整链路。