feat(appointment): 预约商品发布改为可视化配置,并接入表单设计器

商家端补齐预约规则、时段库存与系统表单设计;运营端增加预约工单与订单设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pikachu1995@126.com
2026-09-20 17:06:16 +08:00
parent 50ebc7d6df
commit 1bad9a14db
29 changed files with 4933 additions and 230 deletions

View File

@@ -0,0 +1,135 @@
/**
* 预约商品(APPOINTMENT_GOODS)— 商家端常量
*/
export const APPOINTMENT_GOODS_TYPE = "APPOINTMENT_GOODS";
export const SERVICE_MODE = {
IN_STORE: "IN_STORE",
ON_SITE: "ON_SITE",
};
export const SERVICE_MODE_LABEL = {
IN_STORE: "到店服务",
ON_SITE: "上门服务",
};
export const SLOT_DIVISION_MODE = {
AUTO: "AUTO",
CUSTOM: "CUSTOM",
};
export const BOOKABLE_DATE_MODE = {
EVERY_DAY: "EVERY_DAY",
CUSTOM: "CUSTOM",
};
export const ADVANCE_MODE = {
NONE: "NONE",
REQUIRE_HOURS: "REQUIRE_HOURS",
};
export const CANCEL_MODE = {
NOT_ALLOWED: "NOT_ALLOWED",
ALLOW_BEFORE_HOURS: "ALLOW_BEFORE_HOURS",
};
export const FORM_SUBMIT_MODE = {
PER_APPOINTMENT: "PER_APPOINTMENT",
PER_ORDER: "PER_ORDER",
};
export const WORK_ORDER_STATUS = {
PENDING_CLAIM: "PENDING_CLAIM",
WAIT_VERIFY: "WAIT_VERIFY",
CLAIMED: "CLAIMED",
IN_SERVICE: "IN_SERVICE",
SERVICE_ENDED: "SERVICE_ENDED",
NO_SHOW: "NO_SHOW",
REFUNDED: "REFUNDED",
};
export const WORK_ORDER_STATUS_LABEL = {
PENDING_CLAIM: "待领取",
WAIT_VERIFY: "待核销",
CLAIMED: "已领取",
IN_SERVICE: "服务中",
SERVICE_ENDED: "服务结束",
NO_SHOW: "逾期未到店",
REFUNDED: "已退款",
};
export const WEEKDAY_OPTIONS = [
{ label: "周一", value: 1 },
{ label: "周二", value: 2 },
{ label: "周三", value: 3 },
{ label: "周四", value: 4 },
{ label: "周五", value: 5 },
{ label: "周六", value: 6 },
{ label: "周日", value: 7 },
];
export function isAppointmentGoods(goodsType) {
return goodsType === APPOINTMENT_GOODS_TYPE;
}
export function defaultAppointmentSettings() {
return {
serviceModes: [SERVICE_MODE.IN_STORE],
showRemainingQty: true,
bookableDateMode: BOOKABLE_DATE_MODE.EVERY_DAY,
bookableDays: 1,
bookableCustomStartDate: "",
bookableCustomEndDate: "",
bookableWeekdays: [1, 2, 3, 4, 5, 6, 7],
advanceMode: ADVANCE_MODE.NONE,
advanceHours: 1,
cancelMode: CANCEL_MODE.ALLOW_BEFORE_HOURS,
cancelAheadHours: 2,
formSubmitMode: FORM_SUBMIT_MODE.PER_APPOINTMENT,
systemFormId: null,
};
}
export function defaultSlotDivision() {
return {
slotDivisionMode: SLOT_DIVISION_MODE.AUTO,
serviceDurationMinutes: 60,
autoStartTime: "08:00",
autoEndTime: "18:00",
confirmedSlots: [],
customDrafts: [{ startTime: "08:00", endTime: "09:00" }],
};
}
export function slotKey(slot) {
return `${slot.startTime}-${slot.endTime}`;
}
export function parseFormAnswerGroups(formAnswerJson) {
if (!formAnswerJson) return [];
let data = formAnswerJson;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch (e) {
return [];
}
}
const fields = Array.isArray(data?.fields) ? data.fields : [];
const answers = Array.isArray(data?.answers) ? data.answers : [];
return answers
.map((group) => {
const map = group && typeof group === "object" && !Array.isArray(group) ? group : {};
const source = fields.length
? fields
: Object.keys(map).map((key) => ({ fieldKey: key, fieldLabel: key }));
return source
.map((field) => ({
fieldKey: field.fieldKey,
fieldLabel: field.fieldLabel || field.fieldKey,
value: map[field.fieldKey] ?? "",
}))
.filter((row) => row.value !== "" && row.value != null);
})
.filter((rows) => rows.length);
}

View File

@@ -5,14 +5,20 @@
* @date 2026-08-02
*/
export const E_COUPON_GOODS_TYPE = "E_COUPON";
export const APPOINTMENT_GOODS_TYPE = "APPOINTMENT_GOODS";
export function isECoupon(goodsType) {
return goodsType === E_COUPON_GOODS_TYPE;
}
export function isAppointmentGoods(goodsType) {
return goodsType === APPOINTMENT_GOODS_TYPE;
}
export function goodsTypeLabel(goodsType) {
const map = {
E_COUPON: "电子卡券",
APPOINTMENT_GOODS: "预约商品",
VIRTUAL_GOODS: "虚拟商品",
PHYSICAL_GOODS: "实物商品",
};
@@ -21,6 +27,7 @@ export function goodsTypeLabel(goodsType) {
export function goodsTypeTagType(goodsType) {
if (goodsType === E_COUPON_GOODS_TYPE) return "warning";
if (goodsType === APPOINTMENT_GOODS_TYPE) return "success";
if (goodsType === "VIRTUAL_GOODS") return "info";
return "";
}

View File

@@ -0,0 +1,327 @@
/** 预约系统表单 · 字段类型与 schema(§9.1.1.2) */
export const FORM_FIELD_TYPE = {
CHECKBOX: "CHECKBOX",
CITY: "CITY",
RADIO: "RADIO",
DATE: "DATE",
DATE_RANGE: "DATE_RANGE",
SELECT: "SELECT",
TEXT: "TEXT",
TIME: "TIME",
TIME_RANGE: "TIME_RANGE",
RICH_TEXT: "RICH_TEXT",
IMAGE: "IMAGE",
};
export const FORM_FIELD_PALETTE = [
{ type: FORM_FIELD_TYPE.CHECKBOX, label: "多选框" },
{ type: FORM_FIELD_TYPE.CITY, label: "城市" },
{ type: FORM_FIELD_TYPE.RADIO, label: "单选框" },
{ type: FORM_FIELD_TYPE.DATE, label: "日期" },
{ type: FORM_FIELD_TYPE.DATE_RANGE, label: "日期范围" },
{ type: FORM_FIELD_TYPE.SELECT, label: "下拉框" },
{ type: FORM_FIELD_TYPE.TEXT, label: "文本框" },
{ type: FORM_FIELD_TYPE.TIME, label: "时间" },
{ type: FORM_FIELD_TYPE.TIME_RANGE, label: "时间范围" },
{ type: FORM_FIELD_TYPE.RICH_TEXT, label: "富文本" },
{ type: FORM_FIELD_TYPE.IMAGE, label: "图片" },
];
export const FORM_FIELD_LABEL = Object.fromEntries(
FORM_FIELD_PALETTE.map((item) => [item.type, item.label])
);
export const CITY_LEVEL = {
PROVINCE_CITY: "PROVINCE_CITY",
PROVINCE_CITY_DISTRICT: "PROVINCE_CITY_DISTRICT",
PROVINCE_CITY_DISTRICT_STREET: "PROVINCE_CITY_DISTRICT_STREET",
};
export const TEXT_CONTENT_TYPE = {
TEXT: "TEXT",
MOBILE: "MOBILE",
ID_CARD: "ID_CARD",
EMAIL: "EMAIL",
NUMBER: "NUMBER",
};
export const OPTION_MAX = 20;
/** 标题/选项/提示语均最多十字 */
export const FIELD_LABEL_MAX = 10;
export const OPTION_LABEL_MAX = 10;
export const HINT_TEXT_MAX = 10;
export const RADIO_OPTION_MAX_LEN = OPTION_LABEL_MAX;
export function createFieldKey() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return `f_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function defaultOptions() {
return [
{ label: "选项一", value: createFieldKey() },
{ label: "选项二", value: createFieldKey() },
];
}
export function createField(type) {
const fieldType = String(type || FORM_FIELD_TYPE.TEXT).toUpperCase();
const base = {
field_key: createFieldKey(),
field_type: fieldType,
field_label: FORM_FIELD_LABEL[fieldType] || "文本框",
required: true,
};
switch (fieldType) {
case FORM_FIELD_TYPE.CHECKBOX:
return { ...base, options: defaultOptions() };
case FORM_FIELD_TYPE.RADIO:
return {
field_key: base.field_key,
field_type: fieldType,
field_label: "单选框",
options: defaultOptions(),
default_option_index: 0, // 默认选项一
};
case FORM_FIELD_TYPE.SELECT:
return {
...base,
field_label: "下拉框",
options: defaultOptions(),
default_option_index: 0,
};
case FORM_FIELD_TYPE.CITY:
return {
...base,
field_label: "城市",
city_level: CITY_LEVEL.PROVINCE_CITY_DISTRICT_STREET,
hint_text: "请选择",
};
case FORM_FIELD_TYPE.DATE:
return {
...base,
field_label: "日期",
default_visibility: "SHOW",
default_date_mode: "CURRENT_DATE",
default_date: null,
hint_text: "请选择",
};
case FORM_FIELD_TYPE.DATE_RANGE:
return {
...base,
field_label: "日期范围",
default_visibility: "SHOW",
default_date_mode: "CURRENT_DATE",
default_date_start: null,
default_date_end: null,
hint_text: "请选择",
};
case FORM_FIELD_TYPE.TIME:
return {
...base,
field_label: "时间",
default_visibility: "SHOW",
default_time_mode: "CURRENT_TIME",
default_time: null,
hint_text: "请选择",
};
case FORM_FIELD_TYPE.TIME_RANGE:
return {
...base,
field_label: "时间范围",
default_visibility: "SHOW",
default_time_mode: "CURRENT_TIME",
default_time_start: null,
default_time_end: null,
hint_text: "请选择",
};
case FORM_FIELD_TYPE.TEXT:
return {
...base,
field_label: "文本框",
content_type: TEXT_CONTENT_TYPE.TEXT,
default_value: "",
hint_text: "请填写",
};
case FORM_FIELD_TYPE.RICH_TEXT:
return {
field_key: base.field_key,
field_type: fieldType,
field_label: "富文本",
rich_text_content: "",
};
case FORM_FIELD_TYPE.IMAGE:
return {
...base,
field_label: "上传图片",
max_upload_count: 1,
};
default:
return { ...base, field_type: FORM_FIELD_TYPE.TEXT, content_type: TEXT_CONTENT_TYPE.TEXT, hint_text: "请填写" };
}
}
function normalizeType(raw) {
return String(raw || FORM_FIELD_TYPE.TEXT)
.toUpperCase()
.replace(/-/g, "_");
}
function normalizeOptions(rawOptions) {
if (!Array.isArray(rawOptions) || !rawOptions.length) {
return defaultOptions();
}
return rawOptions.map((item) => {
if (typeof item === "string") {
return { label: item, value: createFieldKey() };
}
return {
label: item?.label ?? item?.name ?? "",
value: item?.value || createFieldKey(),
};
});
}
export function normalizeField(raw, index = 0) {
if (!raw || typeof raw !== "object") {
return createField(FORM_FIELD_TYPE.TEXT);
}
const type = normalizeType(raw.field_type || raw.fieldType || raw.type);
const field = createField(type);
field.field_key = raw.field_key || raw.fieldKey || raw.id || raw.key || field.field_key;
field.field_label = raw.field_label || raw.fieldLabel || raw.label || raw.title || field.field_label;
if (raw.required != null) {
field.required = !!raw.required;
}
if (raw.hint_text != null || raw.hintText != null || raw.hint != null || raw.placeholder != null) {
field.hint_text = raw.hint_text || raw.hintText || raw.hint || raw.placeholder || "";
}
if (field.options) {
field.options = normalizeOptions(raw.options);
}
if (raw.default_option_index != null || raw.defaultOptionIndex != null) {
field.default_option_index = Number(raw.default_option_index ?? raw.defaultOptionIndex) || 0;
}
if (raw.city_level || raw.cityLevel) {
field.city_level = raw.city_level || raw.cityLevel;
}
if (raw.content_type || raw.contentType) {
field.content_type = raw.content_type || raw.contentType;
}
if (raw.default_value != null || raw.defaultValue != null) {
field.default_value = raw.default_value ?? raw.defaultValue ?? "";
}
if (raw.default_visibility || raw.defaultVisibility) {
field.default_visibility = raw.default_visibility || raw.defaultVisibility;
}
if (raw.default_date_mode || raw.defaultDateMode) {
field.default_date_mode = raw.default_date_mode || raw.defaultDateMode;
}
if (raw.default_date || raw.defaultDate) {
field.default_date = raw.default_date || raw.defaultDate;
}
if (raw.default_date_start || raw.defaultDateStart) {
field.default_date_start = raw.default_date_start || raw.defaultDateStart;
}
if (raw.default_date_end || raw.defaultDateEnd) {
field.default_date_end = raw.default_date_end || raw.defaultDateEnd;
}
if (raw.default_time_mode || raw.defaultTimeMode) {
field.default_time_mode = raw.default_time_mode || raw.defaultTimeMode;
}
if (raw.default_time || raw.defaultTime) {
field.default_time = raw.default_time || raw.defaultTime;
}
if (raw.default_time_start || raw.defaultTimeStart) {
field.default_time_start = raw.default_time_start || raw.defaultTimeStart;
}
if (raw.default_time_end || raw.defaultTimeEnd) {
field.default_time_end = raw.default_time_end || raw.defaultTimeEnd;
}
if (raw.rich_text_content != null || raw.richTextContent != null || raw.content != null) {
field.rich_text_content = raw.rich_text_content || raw.richTextContent || raw.content || "";
}
if (raw.max_upload_count != null || raw.maxUploadCount != null) {
field.max_upload_count = Math.min(10, Math.max(1, Number(raw.max_upload_count ?? raw.maxUploadCount) || 1));
}
field.sort_order = raw.sort_order ?? raw.sortOrder ?? index;
return field;
}
export function parseFormSchema(schemaJson) {
if (!schemaJson) {
return [];
}
let data = schemaJson;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch {
return [];
}
}
const list = Array.isArray(data) ? data : data?.fields || [];
return list.map((item, index) => normalizeField(item, index));
}
export function serializeFormSchema(fields) {
return JSON.stringify(
(fields || []).map((field, index) => ({
...field,
sort_order: index,
}))
);
}
export function countFormFields(schemaJson) {
return parseFormSchema(schemaJson).length;
}
export function addOption(options, { radio } = {}) {
const list = [...(options || [])];
if (list.length >= OPTION_MAX) {
return list;
}
list.push({
label: radio ? "" : `选项${list.length + 1}`,
value: createFieldKey(),
});
return list;
}
export function removeOption(options, index) {
const list = [...(options || [])];
if (index < 2 || list.length <= 2) {
return list;
}
list.splice(index, 1);
return list;
}
export function batchAddOptions(options, text, { radio } = {}) {
const list = [...(options || [])];
const lines = String(text || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.slice(0, OPTION_LABEL_MAX));
for (const label of lines) {
if (list.length >= OPTION_MAX) {
break;
}
list.push({ label, value: createFieldKey() });
}
return list;
}
export function hasRequiredSwitch(type) {
// 单选、富文本不配必填
return ![FORM_FIELD_TYPE.RADIO, FORM_FIELD_TYPE.RICH_TEXT].includes(type);
}
export function hasOptions(type) {
return [FORM_FIELD_TYPE.CHECKBOX, FORM_FIELD_TYPE.RADIO, FORM_FIELD_TYPE.SELECT].includes(type);
}