<script setup lang="ts">
|
import { computed, ref, watch } from "vue";
|
import { onLoad } from "@dcloudio/uni-app";
|
import {
|
createProcessInstance,
|
getBpmSimpleDeptList,
|
getBpmSimpleUserList,
|
getProcessDefinition,
|
getProcessInstance,
|
getSimpleDictDataList,
|
getStartApprovalNodes,
|
type BpmApprovalDetail,
|
type BpmApprovalNode,
|
type BpmDept,
|
type BpmProcessDefinition,
|
type BpmUser,
|
type DictData,
|
} from "../../../../api/bpm";
|
import { useAuth } from "../../../../stores/auth";
|
import {
|
buildTree,
|
confirmModal,
|
CANDIDATE_STRATEGY_START_USER_SELECT,
|
FIELD_PERMISSION,
|
flattenTree,
|
MODEL_FORM_TYPE,
|
toast,
|
} from "../../shared";
|
import { buildFormValue, parseFormFields, type FormField } from "../../form-parser";
|
import BpmFormRenderer from "../../components/BpmFormRenderer.vue";
|
import BpmApprovalFlow from "../../components/BpmApprovalFlow.vue";
|
import DeptTreePicker from "../../components/DeptTreePicker.vue";
|
import PickerSheet from "../../../mes/process-design/components/PickerSheet.vue";
|
import type { SheetOption } from "../../../mes/process-design/shared";
|
|
const { state } = useAuth();
|
/**
|
* App 端 onLoad 可能保留 navigateTo 参数中的百分号编码,H5 则通常已自动解码。
|
* 流程定义 id 含冒号,未解码会导致缓存匹配失败并把错误 id 传给后端。
|
*/
|
function decodeRouteParam(value: unknown) {
|
const raw = value === undefined || value === null ? "" : String(value);
|
try {
|
return decodeURIComponent(raw);
|
} catch {
|
return raw;
|
}
|
}
|
|
const SELECTED_DEFINITION_KEY = "bpm_create_selected_definition";
|
const selectedDefinition = ref<BpmProcessDefinition>();
|
|
const definitionId = ref("");
|
/** 调用方显式传入的流程定义 id(从列表页选择 / 重新发起时解析出的最新版本) */
|
const explicitDefinitionId = ref("");
|
const processInstanceId = ref("");
|
|
const loading = ref(true);
|
const submitting = ref(false);
|
const loadError = ref("");
|
/** 首次加载完成(用于控制表单值变化后的审批节点重预测) */
|
const ready = ref(false);
|
|
/** 表单字段与值 */
|
const fields = ref<FormField[]>([]);
|
const formValue = ref<Record<string, any>>({});
|
const permissions = ref<Record<string, string>>({});
|
|
/** 审批节点(发起阶段为预测结果) */
|
const activityNodes = ref<BpmApprovalNode[]>([]);
|
/** 审批节点加载失败原因(非空时在卡片内提示) */
|
const approvalFlowError = ref("");
|
|
/** 发起人自选审批人:activityId -> userIds */
|
const startUserSelectAssignees = ref<Record<string, number[]>>({});
|
|
/* ---------- 下拉/选择面板数据 ---------- */
|
|
const users = ref<BpmUser[]>([]);
|
const depts = ref<BpmDept[]>([]);
|
/**
|
* 部门树。
|
* `/system/dept/simple-list` 返回的是**扁平列表**(靠 parentId 关联),
|
* web 端 dept-select.vue 也是先 `handleTree(data)` 再交给 TreeSelect。
|
* 选择面板与回显共用这棵树,加载完成后建一次即可。
|
*/
|
const deptTree = ref<BpmDept[]>([]);
|
const dicts = ref<DictData[]>([]);
|
|
/** 当前面板的用途:审批人 or 表单字段 */
|
const sheetVisible = ref(false);
|
const sheetTitle = ref("");
|
const sheetOptions = ref<SheetOption[]>([]);
|
const sheetMultiple = ref(false);
|
const sheetSelected = ref<Array<number | string>>([]);
|
|
/** 部门选择面板(树形,独立于通用 PickerSheet) */
|
const deptSheetVisible = ref(false);
|
const deptSheetTitle = ref("选择部门");
|
const deptSheetMultiple = ref(false);
|
const deptSheetSelected = ref<Array<number | string>>([]);
|
|
/**
|
* 字段名 -> 已加载的候选选项,传给 BpmFormRenderer 做回显。
|
* UserSelect / DeptSelect / DictSelect 存的是后端的原始 id 或枚举值,
|
* 渲染器自己不请求数据,没有这张映射表就只能显示「122」这种原始值。
|
*/
|
const fieldOptions = ref<Record<string, SheetOption[]>>({});
|
|
/**
|
* 发起/重新发起阶段表单应保持可填写。
|
* 预测审批节点接口可能返回后续审批节点的只读权限;移动端发起页若直接套用,
|
* 会把姓名、年龄等字段渲染成只读,用户看起来像“输入框点不动”。
|
*/
|
const startPermissions = computed<Record<string, string>>(() => {
|
const writable: Record<string, string> = {};
|
fields.value.forEach(field => {
|
writable[field.field] =
|
permissions.value[field.field] === FIELD_PERMISSION.NONE
|
? FIELD_PERMISSION.NONE
|
: FIELD_PERMISSION.WRITE;
|
});
|
return writable;
|
});
|
|
/** 表单字段选择 */
|
const activeField = ref<FormField>();
|
/** 审批人选择 */
|
const activeNode = ref<BpmApprovalNode>();
|
|
async function ensurePickerData(kind: string) {
|
if (!state.accessToken) return;
|
if (kind === "user" && !users.value.length) {
|
users.value = (await getBpmSimpleUserList(state.accessToken)) || [];
|
}
|
if (kind === "dept" && !depts.value.length) {
|
depts.value = (await getBpmSimpleDeptList(state.accessToken)) || [];
|
deptTree.value = buildTree<BpmDept>(depts.value);
|
}
|
if (kind === "dict" && !dicts.value.length) {
|
dicts.value = (await getSimpleDictDataList(state.accessToken)) || [];
|
}
|
}
|
|
/**
|
* 部门树拍平为「值 -> 名称」选项(带层级缩进)。
|
* 单选时父级也可作为值,所以父级 id 同样要能被查到。
|
*/
|
function deptFlatOptions(): SheetOption[] {
|
return flattenTree<BpmDept>(deptTree.value).map(({ node, depth }) => ({
|
key: Number(node.id),
|
label: `${" ".repeat(depth)}${node.name || node.id}`,
|
}));
|
}
|
|
/**
|
* 目标字段的候选选项(依赖 ensurePickerData 已加载对应数据)。
|
* 与选择面板展示的选项完全一致,因此可以直接用来把值反查成名称。
|
*/
|
function buildFieldOptions(field: FormField): SheetOption[] {
|
if (field.kind === "user") {
|
return users.value.map(u => ({ key: Number(u.id), label: u.nickname || String(u.id) }));
|
}
|
if (field.kind === "dept") return deptFlatOptions();
|
if (field.kind === "dict") {
|
return dicts.value
|
.filter(item => item.dictType === field.dictType)
|
.map(item => ({ key: item.value, label: item.label }));
|
}
|
return [];
|
}
|
|
/**
|
* 缓存字段选项供渲染器回显。
|
* 部门树选项带层级缩进(全角空格),回显时要去掉,否则「部门」后面会跟一串空白。
|
*/
|
function cacheFieldOptions(field: FormField, options: SheetOption[]) {
|
fieldOptions.value[field.field] = options.map(item => ({
|
...item,
|
label: String(item.label).replace(/^\s+/, ""),
|
}));
|
}
|
|
/**
|
* 预加载「已有值」的选择类字段的选项,保证一进页面就能显示名称。
|
* 典型场景:重新发起时表单值来自历史实例,此时人员/部门/字典列表还没请求过。
|
*/
|
async function primeFieldOptions() {
|
for (const field of fields.value) {
|
if (field.kind !== "user" && field.kind !== "dept" && field.kind !== "dict") continue;
|
const value = formValue.value[field.field];
|
const empty =
|
value === undefined || value === null || value === "" ||
|
(Array.isArray(value) && value.length === 0);
|
if (empty) continue;
|
try {
|
await ensurePickerData(field.kind);
|
const options = buildFieldOptions(field);
|
if (options.length) cacheFieldOptions(field, options);
|
} catch {
|
// 预加载失败不阻塞表单:回显退化为原始值,用户点开选择面板仍可用
|
}
|
}
|
}
|
|
/** 点击表单里的 UserSelect / DeptSelect / DictSelect 字段 */
|
async function onFieldPick(field: FormField) {
|
activeField.value = field;
|
activeNode.value = undefined;
|
sheetTitle.value = field.title;
|
sheetMultiple.value = field.multiple;
|
const current = formValue.value[field.field];
|
const currentKeys = Array.isArray(current)
|
? [...current]
|
: current === undefined || current === null || current === ""
|
? []
|
: [current];
|
sheetSelected.value = currentKeys;
|
deptSheetSelected.value = [...currentKeys];
|
|
uni.showLoading({ title: "加载选项…", mask: true });
|
try {
|
await ensurePickerData(field.kind);
|
// 部门走树形面板(对齐 web 端 antd TreeSelect:树状 + 可搜 + 父级可选)
|
if (field.kind === "dept") {
|
if (!deptTree.value.length) {
|
toast("暂无可选项");
|
return;
|
}
|
deptSheetTitle.value = field.title;
|
deptSheetMultiple.value = field.multiple;
|
// 缓存选项供渲染器回显名称(选中后立刻就能显示中文名)
|
cacheFieldOptions(field, deptFlatOptions());
|
deptSheetVisible.value = true;
|
return;
|
}
|
sheetOptions.value = buildFieldOptions(field);
|
if (!sheetOptions.value.length) {
|
// 省市区等复杂组件移动端暂不支持,或确实没有可选项
|
toast(
|
field.kind === "user" || field.kind === "dict"
|
? "暂无可选项"
|
: "该字段类型请在 Web 端填写",
|
);
|
return;
|
}
|
// 缓存下来供渲染器回显名称(选中后立刻就能显示中文名)
|
cacheFieldOptions(field, sheetOptions.value);
|
sheetVisible.value = true;
|
} catch (error) {
|
toast(error instanceof Error ? error.message : "选项加载失败");
|
} finally {
|
uni.hideLoading();
|
}
|
}
|
|
/** 部门树面板确认(单选直接回填,多选已按 web 端 SHOW_CHILD 只保留叶子) */
|
function onDeptConfirm(keys: Array<number | string>) {
|
const field = activeField.value;
|
if (!field) return;
|
if (field.multiple) formValue.value[field.field] = [...keys];
|
else formValue.value[field.field] = keys[0];
|
}
|
|
/** 点击审批流里的「自选审批人」节点 */
|
async function onPickUser(node: BpmApprovalNode) {
|
if (!state.accessToken || !node.id) return;
|
activeNode.value = node;
|
activeField.value = undefined;
|
sheetTitle.value = node.name || "选择审批人";
|
sheetMultiple.value = true;
|
sheetSelected.value = [...(startUserSelectAssignees.value[node.id] || [])];
|
|
uni.showLoading({ title: "加载人员…", mask: true });
|
try {
|
await ensurePickerData("user");
|
sheetOptions.value = users.value.map(u => ({
|
key: Number(u.id),
|
label: u.nickname || String(u.id),
|
}));
|
if (!sheetOptions.value.length) {
|
toast("暂无可选人员");
|
return;
|
}
|
sheetVisible.value = true;
|
} catch (error) {
|
toast(error instanceof Error ? error.message : "人员加载失败");
|
} finally {
|
uni.hideLoading();
|
}
|
}
|
|
function onSheetConfirm(keys: Array<number | string>) {
|
// 审批人
|
if (activeNode.value?.id) {
|
startUserSelectAssignees.value[activeNode.value.id] = keys.map(Number);
|
return;
|
}
|
// 表单字段
|
const field = activeField.value;
|
if (!field) return;
|
if (field.multiple) formValue.value[field.field] = [...keys];
|
else formValue.value[field.field] = keys[0];
|
}
|
|
/** 已选审批人文案:activityId -> 昵称串 */
|
function pickedUserText(): Record<string, string> {
|
const map: Record<string, string> = {};
|
Object.keys(startUserSelectAssignees.value).forEach(activityId => {
|
const ids = startUserSelectAssignees.value[activityId] || [];
|
if (!ids.length) return;
|
map[activityId] = ids
|
.map(id => users.value.find(u => Number(u.id) === id)?.nickname ?? id)
|
.join("、");
|
});
|
return map;
|
}
|
|
/* ---------- 加载 ---------- */
|
|
/** 拉取预测审批节点(与 web 端 getApprovalDetail 一致) */
|
async function loadApprovalNodes(presetVariables?: Record<string, any>) {
|
if (!state.accessToken || !definitionId.value) return;
|
// 重预测前暂存用户已选的审批人,避免节点变化导致已选丢失
|
const previous = { ...startUserSelectAssignees.value };
|
try {
|
const detail: BpmApprovalDetail | undefined = await getStartApprovalNodes(
|
state.accessToken,
|
{
|
processDefinitionId: definitionId.value,
|
activityId: "StartUserNode",
|
processVariablesStr: JSON.stringify(presetVariables ?? {}),
|
},
|
);
|
const nodes = detail?.activityNodes || [];
|
activityNodes.value = nodes;
|
approvalFlowError.value = "";
|
permissions.value = detail?.formFieldsPermission ?? {};
|
// 重建自选审批人槽位:沿用同 activityId 的已选值
|
const assignees: Record<string, number[]> = {};
|
nodes.forEach(node => {
|
if (Number(node.candidateStrategy) !== CANDIDATE_STRATEGY_START_USER_SELECT) return;
|
if (!node.id) return;
|
assignees[node.id] = previous[node.id] || [];
|
});
|
startUserSelectAssignees.value = assignees;
|
} catch (error) {
|
activityNodes.value = [];
|
setApprovalFlowError(error);
|
}
|
}
|
|
function setApprovalFlowError(error: unknown) {
|
approvalFlowError.value = error instanceof Error ? error.message : "审批节点加载失败";
|
}
|
|
async function initForm() {
|
if (!state.accessToken || (!explicitDefinitionId.value && !processInstanceId.value)) {
|
loadError.value = "缺少流程定义";
|
loading.value = false;
|
return;
|
}
|
loading.value = true;
|
loadError.value = "";
|
try {
|
let preset: Record<string, any> | undefined;
|
/** 流程实例所属流程定义的 key(重新发起时用于取「最新版本」定义) */
|
let instanceKey = "";
|
/** 流程实例绑定的定义 id(兜底,可能是旧版本) */
|
let instanceBoundId = "";
|
|
// 重新发起:读取历史表单值并裁掉非表单字段
|
if (processInstanceId.value) {
|
const instance = await getProcessInstance(state.accessToken, processInstanceId.value);
|
if (instance) {
|
instanceKey = instance.processDefinition?.key ?? instance.processDefinitionKey ?? "";
|
instanceBoundId = String(
|
instance.processDefinition?.id ?? instance.processDefinitionId ?? "",
|
);
|
preset = { ...(instance.formVariables || {}) };
|
}
|
}
|
|
// 定义优先级:调用方显式指定的 id(已是最新版本)
|
// > 实例 key 对应的最新版本 > 实例绑定的定义 id
|
let def: BpmProcessDefinition | undefined = selectedDefinition.value;
|
if (!def?.id && explicitDefinitionId.value) {
|
def = await getProcessDefinition(state.accessToken, { id: explicitDefinitionId.value });
|
}
|
if (!def?.id && instanceKey) {
|
def = await getProcessDefinition(state.accessToken, { key: instanceKey });
|
}
|
if (!def?.id && instanceBoundId) {
|
def = await getProcessDefinition(state.accessToken, { id: instanceBoundId });
|
}
|
if (!def?.id) {
|
loadError.value = "未找到对应的流程定义";
|
return;
|
}
|
// 同步最终使用的定义,供审批节点预测与提交使用
|
definitionId.value = String(def.id);
|
|
// 业务表单(CUSTOM)在 web 端会跳到业务自己的创建页(formCustomCreatePath);
|
// App 端没有这些业务页面,只能提示回 Web 端办理。
|
if (Number(def.formType) === MODEL_FORM_TYPE.CUSTOM) {
|
loadError.value = "该流程为业务表单,请在 Web 端发起";
|
toast(loadError.value);
|
setTimeout(() => uni.navigateBack(), 800);
|
return;
|
}
|
uni.setNavigationBarTitle({ title: def.name ? `发起 · ${def.name}` : "发起流程" });
|
fields.value = parseFormFields(def.formFields);
|
const allowed = new Set(fields.value.map(f => f.field));
|
if (preset) {
|
Object.keys(preset).forEach(k => {
|
if (!allowed.has(k)) delete preset![k];
|
});
|
}
|
formValue.value = buildFormValue(fields.value, preset);
|
await loadApprovalNodes(preset);
|
// 预加载「已有值」的动态字段选项,保证一进页面就显示名称而不是原始 id
|
await primeFieldOptions();
|
} catch (error) {
|
loadError.value = error instanceof Error ? error.message : "表单加载失败";
|
} finally {
|
loading.value = false;
|
ready.value = !loadError.value;
|
}
|
}
|
|
/* ---------- 提交 ---------- */
|
|
/** 裁掉非表单字段(与 web 端 submitForm 一致,防后端报错) */
|
function trimmedVariables() {
|
const allowed = new Set(fields.value.map(f => f.field));
|
const variables: Record<string, any> = {};
|
Object.keys(formValue.value).forEach(key => {
|
if (!allowed.has(key)) return;
|
variables[key] = formValue.value[key];
|
});
|
return variables;
|
}
|
|
/** 必填校验 */
|
function firstInvalidMessage() {
|
for (const field of fields.value) {
|
if (!field.required) continue;
|
const value = formValue.value[field.field];
|
const empty =
|
value === undefined || value === null || value === "" ||
|
(Array.isArray(value) && value.length === 0);
|
if (empty) return `请填写${field.title}`;
|
}
|
return "";
|
}
|
|
async function submitForm() {
|
if (!state.accessToken || submitting.value || !definitionId.value) return;
|
|
const invalid = firstInvalidMessage();
|
if (invalid) {
|
toast(invalid);
|
return;
|
}
|
|
// 自选审批人未选齐时拦截
|
for (const node of activityNodes.value) {
|
if (Number(node.candidateStrategy) !== CANDIDATE_STRATEGY_START_USER_SELECT) continue;
|
if (!node.id) continue;
|
const ids = startUserSelectAssignees.value[node.id] || [];
|
if (!ids.length) {
|
toast(`请选择「${node.name || "审批人"}」的审批人`);
|
return;
|
}
|
}
|
|
submitting.value = true;
|
try {
|
await createProcessInstance(state.accessToken, {
|
processDefinitionId: definitionId.value,
|
variables: trimmedVariables(),
|
startUserSelectAssignees: startUserSelectAssignees.value,
|
});
|
toast("发起成功");
|
setTimeout(() => {
|
uni.redirectTo({ url: "/pages/bpm/process-instance/my/index" });
|
}, 600);
|
} catch (error) {
|
toast(error instanceof Error ? error.message : "发起失败");
|
} finally {
|
submitting.value = false;
|
}
|
}
|
|
/** 取消:有填写内容时二次确认(使用系统返回) */
|
async function handleCancel() {
|
const hasInput = Object.values(formValue.value).some(v =>
|
Array.isArray(v) ? v.length : v !== undefined && v !== null && v !== "",
|
);
|
if (hasInput) {
|
const ok = await confirmModal("确认放弃当前填写并返回?");
|
if (!ok) return;
|
}
|
uni.navigateBack();
|
}
|
|
onLoad(query => {
|
explicitDefinitionId.value = decodeRouteParam(query?.id);
|
definitionId.value = explicitDefinitionId.value;
|
processInstanceId.value = decodeRouteParam(query?.processInstanceId);
|
try {
|
const cachedValue = uni.getStorageSync(SELECTED_DEFINITION_KEY) as
|
| BpmProcessDefinition
|
| string
|
| undefined;
|
const cached = typeof cachedValue === "string"
|
? (JSON.parse(cachedValue) as BpmProcessDefinition)
|
: cachedValue;
|
if (cached?.id && String(cached.id) === explicitDefinitionId.value) {
|
selectedDefinition.value = cached;
|
}
|
uni.removeStorageSync(SELECTED_DEFINITION_KEY);
|
} catch {
|
selectedDefinition.value = undefined;
|
}
|
initForm();
|
});
|
|
/**
|
* 表单值变化后重新预测审批节点(对齐 web 端 watch(detailForm.value.value))。
|
* 条件分支流程的审批链路依赖表单变量,改了表单就要重算,否则审批流展示会过期。
|
* 仅在首次加载完成后生效(ready),避免初始化赋值本身触发重复请求。
|
*/
|
let repredictTimer: ReturnType<typeof setTimeout> | undefined;
|
watch(
|
formValue,
|
() => {
|
if (!ready.value || !definitionId.value) return;
|
if (repredictTimer) clearTimeout(repredictTimer);
|
repredictTimer = setTimeout(() => {
|
loadApprovalNodes(formValue.value);
|
}, 400);
|
},
|
{ deep: true },
|
);
|
</script>
|
|
<template>
|
<view class="page">
|
<view v-if="loadError" class="state error" @click="initForm">{{ loadError }},点击重试</view>
|
<view v-else-if="loading" class="state">加载中...</view>
|
|
<scroll-view v-else class="content" scroll-y>
|
<!-- 审批流程(与 web 端右侧审批流一致) -->
|
<view class="card">
|
<text class="card-title">审批流程</text>
|
<text class="card-sub">提交后将按以下流程流转</text>
|
<BpmApprovalFlow
|
:nodes="activityNodes"
|
:selectable="true"
|
:picked-text="pickedUserText()"
|
:empty-text="approvalFlowError || '该流程未配置审批节点'"
|
@pick-user="onPickUser"
|
/>
|
</view>
|
|
<!-- 表单信息 -->
|
<view class="card">
|
<text class="card-title">表单信息</text>
|
<BpmFormRenderer
|
v-model="formValue"
|
:fields="fields"
|
:permissions="startPermissions"
|
:field-options="fieldOptions"
|
@pick="onFieldPick"
|
/>
|
</view>
|
</scroll-view>
|
|
<view v-if="!loading && !loadError" class="footer">
|
<button class="btn ghost" @click="handleCancel">取消</button>
|
<button class="btn primary" :loading="submitting" :disabled="submitting" @click="submitForm">
|
发起
|
</button>
|
</view>
|
|
<PickerSheet
|
v-model:show="sheetVisible"
|
:title="sheetTitle"
|
:multiple="sheetMultiple"
|
:items="sheetOptions"
|
:selected-keys="sheetSelected"
|
@confirm="onSheetConfirm"
|
/>
|
|
<DeptTreePicker
|
v-model:show="deptSheetVisible"
|
:title="deptSheetTitle"
|
:multiple="deptSheetMultiple"
|
:nodes="deptTree"
|
:selected-keys="deptSheetSelected"
|
@confirm="onDeptConfirm"
|
/>
|
</view>
|
</template>
|
|
<style lang="scss" scoped>
|
.page{height:100vh;display:flex;flex-direction:column;padding:0 30rpx;box-sizing:border-box;overflow:hidden;background:#f3f6fb}
|
/* 底部固定栏占位:给内容留出足够的下方空间,避免最后一个字段被按钮挡住 */
|
.content{height:0;flex:1;padding:20rpx 0 200rpx;box-sizing:border-box}
|
.card{padding:24rpx 22rpx;margin-bottom:18rpx;background:#fff;border-radius:16rpx;box-shadow:0 10rpx 24rpx rgba(43,69,107,.08)}
|
.card-title{display:block;color:#20334c;font-size:28rpx;font-weight:600}
|
.card-sub{display:block;margin:6rpx 0 20rpx;color:#93a1b3;font-size:23rpx}
|
.card-title + .bpm-form{padding-top:20rpx}
|
/* 底部操作栏固定在屏幕底部(position:fixed 在 H5 与小程序都以可视区为基准,
|
不受 H5 下 100vh 与导航栏高度不一致的影响) */
|
.footer{position:fixed;z-index:20;right:0;bottom:0;left:0;display:flex;gap:20rpx;padding:16rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));background:#fff;border-top:1rpx solid #e9eef5;box-shadow:0 -6rpx 20rpx rgba(43,69,107,.06)}
|
.btn{flex:1;font-size:29rpx;border-radius:14rpx}
|
.btn.ghost{color:#3d5069;background:#fff;border:1rpx solid #dbe4ef}
|
.btn.primary{color:#fff;background:var(--color-primary, #1769d8)}
|
.state{flex:1;padding:60rpx 20rpx;color:#8795a8;font-size:25rpx;text-align:center}
|
.state.error{color:#c65050}
|
</style>
|