feat: migrate app routes and business modules
This commit is contained in:
-3561
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
|
||||
|
||||
export const AUTH_VERIFICATION_OPERATION = Object.freeze({
|
||||
PASSWORD_LOGIN: "password-login",
|
||||
SMS_LOGIN: "sms-login",
|
||||
@@ -121,22 +123,4 @@ export const normalizeTacSuccess = (value, expectedRequestId) => {
|
||||
return { requestId: value.requestId, validToken, expireSeconds };
|
||||
};
|
||||
|
||||
export const isSmsDeliveryOutcomeUnknown = (error) => {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if (
|
||||
error.code === "REQUEST_TIMEOUT" ||
|
||||
error.code === "NETWORK_ERROR" ||
|
||||
error.code === "RESPONSE_INVALID" ||
|
||||
error.code === "REQUEST_CANCELLED"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (error.code === "HTTP_ERROR" || error.code === "BUSINESS_ERROR") {
|
||||
const status = Number(
|
||||
error.code === "HTTP_ERROR" ? error.httpStatus : error.businessCode,
|
||||
);
|
||||
if (!Number.isInteger(status)) return true;
|
||||
return !(status >= 400 && status < 500 && status !== 408);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
export const isSmsDeliveryOutcomeUnknown = isWriteOutcomeUnknown;
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
export const DEFAULT_MALE_AVATAR =
|
||||
"/static/assets/foundation/transparent/mjpc0703_A_cartoon_illustration_of_a_boy_wearing_a_red_Chinese__0@2x.png";
|
||||
"/static/assets/foundation/transparent/default-avatar-male.png";
|
||||
|
||||
export const DEFAULT_FEMALE_AVATAR =
|
||||
"/static/assets/foundation/transparent/loyel003_Ancient_Beauty_Wearing_Tang_Dynasty_ClothingLooking_to_07cb98a9-ec37-4620-a032-ebe511fa93b5@2x.png";
|
||||
"/static/assets/foundation/transparent/default-avatar-female.png";
|
||||
|
||||
export const getDefaultAvatar = (sex) =>
|
||||
String(sex ?? "").trim() === "1"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export const runtimeConfig = {
|
||||
mode: 'remote',
|
||||
baseUrl: 'https://backend-api.ddxcjp.cn',
|
||||
clientId: '428a8310cd442757ae699df5d894f051',
|
||||
tenantId: '000000'
|
||||
}
|
||||
|
||||
export const isMockMode = () => runtimeConfig.mode === 'mock'
|
||||
export const hasRemoteConfig = () => runtimeConfig.mode === 'remote' && Boolean(runtimeConfig.baseUrl && runtimeConfig.clientId)
|
||||
|
||||
export const resolveRuntimeMode = () => {
|
||||
if (isMockMode()) return 'mock'
|
||||
if (hasRemoteConfig()) return 'remote'
|
||||
throw new Error('运行模式配置无效:只允许 mock 或配置完整的 remote')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const minuteTimestamp = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::\d{2})?/;
|
||||
|
||||
export const formatMinuteTimestamp = (value) => {
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(minuteTimestamp);
|
||||
return match ? `${match[1]} ${match[2]}` : text;
|
||||
};
|
||||
@@ -5,6 +5,18 @@ export const GENEALOGY_ACCESS_PRESET = Object.freeze({
|
||||
PUBLIC_APPLY: "PUBLIC_APPLY",
|
||||
});
|
||||
|
||||
export const GENEALOGY_VISIBILITY = Object.freeze({
|
||||
PRIVATE: "0",
|
||||
PUBLIC: "1",
|
||||
MEMBER_ONLY: "2",
|
||||
});
|
||||
|
||||
export const GENEALOGY_JOIN_MODE = Object.freeze({
|
||||
CLOSED: "0",
|
||||
REVIEW: "1",
|
||||
INVITATION: "2",
|
||||
});
|
||||
|
||||
export const GENEALOGY_ACCESS_PRESET_OPTIONS = Object.freeze([
|
||||
Object.freeze({
|
||||
value: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||
@@ -19,17 +31,22 @@ export const GENEALOGY_ACCESS_PRESET_OPTIONS = Object.freeze([
|
||||
export const isGenealogyAccessPreset = (preset) =>
|
||||
Object.values(GENEALOGY_ACCESS_PRESET).includes(preset);
|
||||
|
||||
// 当前导出只在字段说明中给出 0/1/2 示例,并未提供正式 enum。这里锁住页面
|
||||
// 当前能表达的两个组合,未知组合一律返回 null;邀请码模式必须等后端合同补齐,
|
||||
// 不能把 PUBLIC_APPLY 冒充 joinMode=2。
|
||||
export const isGenealogyVisibility = (value) =>
|
||||
Object.values(GENEALOGY_VISIBILITY).includes(value);
|
||||
|
||||
export const isGenealogyJoinMode = (value) =>
|
||||
Object.values(GENEALOGY_JOIN_MODE).includes(value);
|
||||
|
||||
// OpenAPI 为两个字段分别声明了 0/1/2,但当前产品只提供以下两个完整预设。
|
||||
// 其他合法组合不能被悄悄改写成相近选项,页面应明确显示“访问规则待确认”。
|
||||
const apiAccessByPreset = Object.freeze({
|
||||
[GENEALOGY_ACCESS_PRESET.MEMBER_ONLY]: Object.freeze({
|
||||
visibility: "2",
|
||||
joinMode: "0",
|
||||
visibility: GENEALOGY_VISIBILITY.MEMBER_ONLY,
|
||||
joinMode: GENEALOGY_JOIN_MODE.CLOSED,
|
||||
}),
|
||||
[GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY]: Object.freeze({
|
||||
visibility: "1",
|
||||
joinMode: "1",
|
||||
visibility: GENEALOGY_VISIBILITY.PUBLIC,
|
||||
joinMode: GENEALOGY_JOIN_MODE.REVIEW,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { GENERATION_POEM_STATUS } from '@/services/api/generation-poem-contract.js'
|
||||
|
||||
export { GENERATION_POEM_STATUS }
|
||||
|
||||
// 三个限制逐一对应 GenerationPoemBatchBody:输入总长 26000、单代文字
|
||||
// 最多 50 字符、单批最多 500 个世代,不能再合并成一个含义模糊的“长度”。
|
||||
export const MAX_GENERATION_COUNT = 500
|
||||
export const MAX_GENERATION_TEXT_LENGTH = 50
|
||||
export const MAX_GENERATION_POEM_INPUT_LENGTH = 26000
|
||||
|
||||
const separatorPattern = /[\s,,;;、/|]/u
|
||||
const separatorRunPattern = /[\s,,;;、/|]+/u
|
||||
const forbiddenControlPattern = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u
|
||||
const codePointLength = (value) => Array.from(value).length
|
||||
|
||||
const normalizeGenerationText = (value) => {
|
||||
const text = String(value ?? '').trim()
|
||||
if (
|
||||
!text ||
|
||||
codePointLength(text) > MAX_GENERATION_TEXT_LENGTH ||
|
||||
forbiddenControlPattern.test(text)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// 按 OpenAPI 的批量输入规则解析:没有分隔符时每个 Unicode code point 对应
|
||||
// 一代;存在空格、逗号、分号、顿号、斜杠或竖线时,每个分段可包含多字。
|
||||
export const validateGenerationPoemText = (value) => {
|
||||
const input = String(value ?? '')
|
||||
if (!input.trim()) {
|
||||
return Object.freeze({ valid: false, generations: [], message: '请录入字辈内容' })
|
||||
}
|
||||
if (codePointLength(input) > MAX_GENERATION_POEM_INPUT_LENGTH) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations: [],
|
||||
message: `字辈输入最多 ${MAX_GENERATION_POEM_INPUT_LENGTH} 个字符`,
|
||||
})
|
||||
}
|
||||
if (forbiddenControlPattern.test(input)) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations: [],
|
||||
message: '字辈内容包含不支持的控制字符',
|
||||
})
|
||||
}
|
||||
|
||||
const generations = separatorPattern.test(input)
|
||||
? input.split(separatorRunPattern).map((item) => item.trim()).filter(Boolean)
|
||||
: Array.from(input)
|
||||
if (!generations.length) {
|
||||
return Object.freeze({ valid: false, generations, message: '请录入字辈内容' })
|
||||
}
|
||||
if (generations.length > MAX_GENERATION_COUNT) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations,
|
||||
message: `一次最多录入 ${MAX_GENERATION_COUNT} 个世代`,
|
||||
})
|
||||
}
|
||||
if (generations.some((item) => normalizeGenerationText(item) === null)) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations,
|
||||
message: `每代字辈文字不能为空且最多 ${MAX_GENERATION_TEXT_LENGTH} 个字符`,
|
||||
})
|
||||
}
|
||||
return Object.freeze({ valid: true, generations, message: '' })
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// generationNo 来自 int64。当前页面必须做加法与排序,因此只接受 JavaScript
|
||||
// 可以精确表达的正安全整数;超出范围时失败关闭,不能把两个世代舍入成同一值。
|
||||
export const MAX_GENERATION_NO = Number.MAX_SAFE_INTEGER
|
||||
|
||||
// 三个限制逐一对应 GenerationPoemBatchBody:输入总长 26000、单代文字
|
||||
// 最多 50 字符、单批最多 500 个世代,不能再合并成一个含义模糊的“长度”。
|
||||
export const MAX_GENERATION_COUNT = 500
|
||||
export const MAX_GENERATION_TEXT_LENGTH = 50
|
||||
export const MAX_GENERATION_POEM_INPUT_LENGTH = 26000
|
||||
export const GENERATION_POEM_STATUS = Object.freeze({
|
||||
ACTIVE: '0',
|
||||
DISABLED: '1',
|
||||
})
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : null
|
||||
}
|
||||
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) return null
|
||||
const number = Number(value)
|
||||
return Number.isSafeInteger(number) ? number : null
|
||||
}
|
||||
|
||||
// 路由查询始终先经过这里:缺省值可以回落,显式传入的非法值必须失败关闭,
|
||||
// 不能用 Number(...) || fallback 把 0、小数或科学计数法悄悄改成另一代。
|
||||
export const normalizeGenerationNumber = (value, fallback) => {
|
||||
const candidate = value === undefined || value === null || value === ''
|
||||
? fallback
|
||||
: value
|
||||
const number = normalizePositiveInteger(candidate)
|
||||
return number !== null && number <= MAX_GENERATION_NO ? number : null
|
||||
}
|
||||
|
||||
const separatorPattern = /[\s,,;;、/|]/u
|
||||
const separatorRunPattern = /[\s,,;;、/|]+/u
|
||||
const forbiddenControlPattern = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u
|
||||
const codePointLength = (value) => Array.from(value).length
|
||||
|
||||
const normalizeGenerationText = (value) => {
|
||||
const text = String(value ?? '').trim()
|
||||
if (
|
||||
!text ||
|
||||
codePointLength(text) > MAX_GENERATION_TEXT_LENGTH ||
|
||||
forbiddenControlPattern.test(text)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// 按 OpenAPI 的批量输入规则解析:没有分隔符时每个 Unicode code point 对应
|
||||
// 一代;存在空格、逗号、分号、顿号、斜杠或竖线时,每个分段可包含多字。
|
||||
export const validateGenerationPoemText = (value) => {
|
||||
const input = String(value ?? '')
|
||||
if (!input.trim()) {
|
||||
return Object.freeze({ valid: false, generations: [], message: '请录入字辈内容' })
|
||||
}
|
||||
if (codePointLength(input) > MAX_GENERATION_POEM_INPUT_LENGTH) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations: [],
|
||||
message: `字辈输入最多 ${MAX_GENERATION_POEM_INPUT_LENGTH} 个字符`,
|
||||
})
|
||||
}
|
||||
if (forbiddenControlPattern.test(input)) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations: [],
|
||||
message: '字辈内容包含不支持的控制字符',
|
||||
})
|
||||
}
|
||||
|
||||
const generations = separatorPattern.test(input)
|
||||
? input.split(separatorRunPattern).map((item) => item.trim()).filter(Boolean)
|
||||
: Array.from(input)
|
||||
if (!generations.length) {
|
||||
return Object.freeze({ valid: false, generations, message: '请录入字辈内容' })
|
||||
}
|
||||
if (generations.length > MAX_GENERATION_COUNT) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations,
|
||||
message: `一次最多录入 ${MAX_GENERATION_COUNT} 个世代`,
|
||||
})
|
||||
}
|
||||
if (generations.some((item) => normalizeGenerationText(item) === null)) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations,
|
||||
message: `每代字辈文字不能为空且最多 ${MAX_GENERATION_TEXT_LENGTH} 个字符`,
|
||||
})
|
||||
}
|
||||
return Object.freeze({ valid: true, generations, message: '' })
|
||||
}
|
||||
|
||||
const normalizeExistingRow = (row) => {
|
||||
const generationNo = normalizeGenerationNumber(row?.generationNo)
|
||||
const generationText = normalizeGenerationText(row?.generationText)
|
||||
if (
|
||||
generationNo === null ||
|
||||
generationText === null ||
|
||||
!Object.values(GENERATION_POEM_STATUS).includes(row?.status)
|
||||
) {
|
||||
throw new TypeError('已有字辈记录不符合 GenerationPoemView 合同')
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
generationNo,
|
||||
generationText,
|
||||
status: row.status,
|
||||
current: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 只检查已建立字辈的起点到本批起始代之间,避免把家谱尚未录入的更早世代
|
||||
// 当成缺口;算法只遍历已有记录,能够安全处理几十代、几百代甚至稀疏数据。
|
||||
export const findFirstGenerationGap = (
|
||||
rows,
|
||||
startGeneration,
|
||||
sequenceStartGeneration,
|
||||
) => {
|
||||
const start = normalizeGenerationNumber(startGeneration)
|
||||
if (start === null) return null
|
||||
const generations = [...new Set(
|
||||
(Array.isArray(rows) ? rows : [])
|
||||
.filter((row) => row?.status !== GENERATION_POEM_STATUS.DISABLED)
|
||||
.map((row) => normalizeGenerationNumber(row?.generationNo))
|
||||
.filter((generationNo) => generationNo !== null && generationNo < start),
|
||||
)].sort((left, right) => left - right)
|
||||
if (!generations.length) return null
|
||||
const explicitSequenceStart = sequenceStartGeneration === undefined
|
||||
? null
|
||||
: normalizeGenerationNumber(sequenceStartGeneration)
|
||||
if (sequenceStartGeneration !== undefined && explicitSequenceStart === null) {
|
||||
throw new TypeError('字辈序列起点无效')
|
||||
}
|
||||
let expected = explicitSequenceStart ?? generations[0]
|
||||
for (const generationNo of generations) {
|
||||
if (generationNo > expected) return expected
|
||||
expected = generationNo + 1
|
||||
}
|
||||
return expected < start ? expected : null
|
||||
}
|
||||
|
||||
// 批量维护只替换本次覆盖区间。disableMissing=true 时,后续遗漏记录会被
|
||||
// 标成停用但仍保留,确保历史数据不会因一次编辑被物理删除。
|
||||
export const mergeGenerationPoemRows = ({
|
||||
existingRows,
|
||||
generationTexts,
|
||||
startGeneration,
|
||||
currentGeneration,
|
||||
disableMissing,
|
||||
}) => {
|
||||
const start = normalizeGenerationNumber(startGeneration)
|
||||
const current = normalizeGenerationNumber(currentGeneration)
|
||||
if (typeof disableMissing !== 'boolean') {
|
||||
throw new TypeError('disableMissing 必须是布尔值')
|
||||
}
|
||||
if (!Array.isArray(generationTexts) || !generationTexts.length) {
|
||||
throw new TypeError('字辈合并参数无效')
|
||||
}
|
||||
if (generationTexts.length > MAX_GENERATION_COUNT) {
|
||||
throw new RangeError(`一次最多 ${MAX_GENERATION_COUNT} 个世代`)
|
||||
}
|
||||
const nextGenerationTexts = generationTexts.map(normalizeGenerationText)
|
||||
if (nextGenerationTexts.some((item) => item === null)) {
|
||||
throw new TypeError('单代字辈文字无效')
|
||||
}
|
||||
if (start === null || current === null) throw new TypeError('字辈合并参数无效')
|
||||
if (start > MAX_GENERATION_NO - nextGenerationTexts.length + 1) {
|
||||
throw new RangeError('字辈世代超出支持范围')
|
||||
}
|
||||
const end = start + nextGenerationTexts.length - 1
|
||||
|
||||
const rowsByGeneration = new Map()
|
||||
for (const item of Array.isArray(existingRows) ? existingRows : []) {
|
||||
const row = normalizeExistingRow(item)
|
||||
if (rowsByGeneration.has(row.generationNo)) {
|
||||
throw new TypeError('已有字辈包含重复的世代记录')
|
||||
}
|
||||
rowsByGeneration.set(row.generationNo, row)
|
||||
}
|
||||
|
||||
for (let index = 0; index < nextGenerationTexts.length; index += 1) {
|
||||
const generationNo = start + index
|
||||
const existing = rowsByGeneration.get(generationNo)
|
||||
rowsByGeneration.set(generationNo, {
|
||||
...existing,
|
||||
generationNo,
|
||||
generationText: nextGenerationTexts[index],
|
||||
status: GENERATION_POEM_STATUS.ACTIVE,
|
||||
current: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (disableMissing) {
|
||||
for (const [generationNo, row] of rowsByGeneration) {
|
||||
if (generationNo > end) {
|
||||
rowsByGeneration.set(generationNo, {
|
||||
...row,
|
||||
status: GENERATION_POEM_STATUS.DISABLED,
|
||||
current: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...rowsByGeneration.values()]
|
||||
.sort((left, right) => left.generationNo - right.generationNo)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
current:
|
||||
row.status === GENERATION_POEM_STATUS.ACTIVE &&
|
||||
row.generationNo === current,
|
||||
}))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appApi } from "@/utils/api.js";
|
||||
import { fileUploadApi } from "@/services/api/file-upload-service.js";
|
||||
import { calcMD5Bytes } from "@/utils/md5.js";
|
||||
|
||||
const createUploadError = (message, code) => {
|
||||
@@ -50,9 +50,9 @@ const pickBrowserImage = () => new Promise((resolve, reject) => {
|
||||
}
|
||||
const reader = new globalThis.FileReader();
|
||||
reader.onload = () => {
|
||||
const data = reader.result;
|
||||
const fileData = reader.result;
|
||||
cleanup();
|
||||
if (!(data instanceof ArrayBuffer)) {
|
||||
if (!(fileData instanceof ArrayBuffer)) {
|
||||
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ const pickBrowserImage = () => new Promise((resolve, reject) => {
|
||||
fileName: browserFile.name || "image",
|
||||
size: browserFile.size,
|
||||
contentType: browserFile.type || "image/*",
|
||||
data,
|
||||
data: fileData,
|
||||
});
|
||||
};
|
||||
reader.onerror = () => {
|
||||
@@ -109,9 +109,9 @@ const pickBrowserVideo = () => new Promise((resolve, reject) => {
|
||||
}
|
||||
const reader = new globalThis.FileReader();
|
||||
reader.onload = () => {
|
||||
const data = reader.result;
|
||||
const fileData = reader.result;
|
||||
cleanup();
|
||||
if (!(data instanceof ArrayBuffer)) {
|
||||
if (!(fileData instanceof ArrayBuffer)) {
|
||||
reject(createUploadError("读取视频数据失败", "VIDEO_READ_INVALID"));
|
||||
return;
|
||||
}
|
||||
@@ -120,7 +120,7 @@ const pickBrowserVideo = () => new Promise((resolve, reject) => {
|
||||
fileName: browserFile.name || "video",
|
||||
size: browserFile.size,
|
||||
contentType: browserFile.type || "video/*",
|
||||
data,
|
||||
data: fileData,
|
||||
});
|
||||
};
|
||||
reader.onerror = () => {
|
||||
@@ -165,8 +165,8 @@ const readNativeFile = (filePath) => new Promise((resolve, reject) => {
|
||||
(file) => {
|
||||
const reader = new globalThis.plus.io.FileReader();
|
||||
reader.onloadend = (event) => {
|
||||
const data = event?.target?.result ?? reader.result;
|
||||
if (!(data instanceof ArrayBuffer)) {
|
||||
const fileData = event?.target?.result ?? reader.result;
|
||||
if (!(fileData instanceof ArrayBuffer)) {
|
||||
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
|
||||
return;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ const readNativeFile = (filePath) => new Promise((resolve, reject) => {
|
||||
fileName: file.name || "image",
|
||||
size: file.size,
|
||||
contentType: file.type || "image/*",
|
||||
data,
|
||||
data: fileData,
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(createUploadError("读取图片数据失败", "IMAGE_READ_FAILED"));
|
||||
@@ -187,20 +187,6 @@ const readNativeFile = (filePath) => new Promise((resolve, reject) => {
|
||||
);
|
||||
});
|
||||
|
||||
export const toConsumerOssId = (ossId) => {
|
||||
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
|
||||
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
|
||||
}
|
||||
const numericId = Number(ossId);
|
||||
if (!Number.isSafeInteger(numericId)) {
|
||||
throw createUploadError(
|
||||
"文件已上传,但服务返回的文件 ID 超出 APP 安全整数范围;需由后端统一文件 ID 合同后才能关联业务数据",
|
||||
"OSS_ID_UNSAFE",
|
||||
);
|
||||
}
|
||||
return numericId;
|
||||
};
|
||||
|
||||
const normalizeUploadReceiptOssId = (ossId) => {
|
||||
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
|
||||
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
|
||||
@@ -233,7 +219,7 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
||||
chunkSize: file.size,
|
||||
contentType: file.contentType,
|
||||
};
|
||||
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
|
||||
const initialized = await fileUploadApi.initializeResumableUpload(initPayload, { requestController });
|
||||
if (initialized.instant) return toUploadReceipt(initialized);
|
||||
const chunkPayload = {
|
||||
uploadId: initialized.uploadId,
|
||||
@@ -241,11 +227,11 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
||||
chunkMd5: fileMd5,
|
||||
};
|
||||
if (file.browserFile) {
|
||||
await appApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
|
||||
await fileUploadApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
|
||||
} else {
|
||||
await appApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
|
||||
await fileUploadApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
|
||||
}
|
||||
const completed = await appApi.completeResumableUpload({
|
||||
const completed = await fileUploadApi.completeResumableUpload({
|
||||
uploadId: initialized.uploadId,
|
||||
fileName: file.fileName,
|
||||
fileMd5,
|
||||
@@ -276,7 +262,7 @@ export const pickAndUploadVideo = async ({ requestController = null } = {}) => {
|
||||
chunkSize: file.size,
|
||||
contentType: file.contentType || "video/*",
|
||||
};
|
||||
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
|
||||
const initialized = await fileUploadApi.initializeResumableUpload(initPayload, { requestController });
|
||||
if (initialized.instant) return toUploadReceipt(initialized);
|
||||
const chunkPayload = {
|
||||
uploadId: initialized.uploadId,
|
||||
@@ -284,11 +270,11 @@ export const pickAndUploadVideo = async ({ requestController = null } = {}) => {
|
||||
chunkMd5: fileMd5,
|
||||
};
|
||||
if (file.browserFile) {
|
||||
await appApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
|
||||
await fileUploadApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
|
||||
} else {
|
||||
await appApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
|
||||
await fileUploadApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
|
||||
}
|
||||
const completed = await appApi.completeResumableUpload({
|
||||
const completed = await fileUploadApi.completeResumableUpload({
|
||||
uploadId: initialized.uploadId,
|
||||
fileName,
|
||||
fileMd5,
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ROOT_ROUTE_KEYS,
|
||||
getRoute,
|
||||
getRouteKeyByPath,
|
||||
} from "./navigation-routes.js";
|
||||
} from "./routes.js";
|
||||
|
||||
const navigationResults = new Map();
|
||||
const pageInstanceTokens = new WeakMap();
|
||||
@@ -26,35 +26,11 @@ const getPageInstanceToken = (page) => {
|
||||
return token;
|
||||
};
|
||||
|
||||
const snapshotDataRecord = (name, value) => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
const copyDataRecord = (name, value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${name} 必须是对象`);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`${name} 必须是普通对象`);
|
||||
}
|
||||
const snapshot = Object.create(null);
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
for (const key of Reflect.ownKeys(descriptors)) {
|
||||
if (typeof key !== "string") {
|
||||
throw new TypeError(`${name} 不接受 Symbol 字段`);
|
||||
}
|
||||
const descriptor = descriptors[key];
|
||||
if (!descriptor.enumerable) {
|
||||
throw new TypeError(`${name} 字段 ${key} 必须可枚举`);
|
||||
}
|
||||
if (!hasOwn(descriptor, "value")) {
|
||||
throw new TypeError(`${name} 字段 ${key} 不得使用访问器`);
|
||||
}
|
||||
Object.defineProperty(snapshot, key, {
|
||||
value: descriptor.value,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
}
|
||||
return Object.freeze(snapshot);
|
||||
return Object.freeze({ ...value });
|
||||
};
|
||||
|
||||
const assertScalarString = (name, value) => {
|
||||
@@ -66,7 +42,7 @@ const assertScalarString = (name, value) => {
|
||||
const validateRouteParams = (routeKey, params, requireAll) => {
|
||||
const route = getRoute(routeKey);
|
||||
if (!route) throw new Error(`未知路由键:${routeKey}`);
|
||||
const normalizedParams = snapshotDataRecord(`${routeKey} 导航参数`, params);
|
||||
const normalizedParams = copyDataRecord(`${routeKey} 导航参数`, params);
|
||||
|
||||
const allowed = new Set([...route.requiredParams, ...route.optionalParams]);
|
||||
for (const name of Object.keys(normalizedParams)) {
|
||||
@@ -136,15 +112,7 @@ const getPageParams = (page, route) => {
|
||||
continue;
|
||||
}
|
||||
for (const name of routeFields) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(candidate, name);
|
||||
if (!descriptor) continue;
|
||||
if (!descriptor.enumerable) {
|
||||
throw new TypeError(`页面导航参数 ${name} 必须可枚举`);
|
||||
}
|
||||
if (!hasOwn(descriptor, "value")) {
|
||||
throw new TypeError(`页面导航参数 ${name} 不得使用访问器`);
|
||||
}
|
||||
params[name] = descriptor.value;
|
||||
if (hasOwn(candidate, name)) params[name] = candidate[name];
|
||||
}
|
||||
}
|
||||
return params;
|
||||
@@ -276,9 +244,9 @@ const asNavigationPromise = (execute) => {
|
||||
const validateNavigationResult = (routeKey, result) => {
|
||||
const route = getRoute(routeKey);
|
||||
if (!route) throw new Error(`未知路由键:${routeKey}`);
|
||||
const normalizedResult = snapshotDataRecord(`${routeKey} 导航结果`, result);
|
||||
const normalizedResult = copyDataRecord(`${routeKey} 导航结果`, result);
|
||||
|
||||
const fields = Reflect.ownKeys(normalizedResult);
|
||||
const fields = Object.keys(normalizedResult);
|
||||
const allowedFields = new Set(["operation", "entityId", "refresh"]);
|
||||
for (const field of fields) {
|
||||
if (typeof field !== "string" || !allowedFields.has(field)) {
|
||||
@@ -411,7 +379,7 @@ const activateExistingSinglePage = (routeKey, params, url) => {
|
||||
const targetIndex = targetIndexes[0] ?? -1;
|
||||
if (targetIndex < 0) return pushPage(url);
|
||||
|
||||
// T03 是当前唯一 single 页面。复用实例前必须锁定家谱上下文;若跨家谱强行
|
||||
// 成员档案是当前唯一 single 页面。复用实例前必须锁定家谱上下文;若跨家谱强行
|
||||
// 复用,成员轨迹和一次性结果都会串到错误领域,因此明确失败而不是猜测回退。
|
||||
const existingParams = validateRouteParams(
|
||||
routeKey,
|
||||
@@ -424,7 +392,7 @@ const activateExistingSinglePage = (routeKey, params, url) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = validateNavigationResult(routeKey, {
|
||||
const navigationResult = validateNavigationResult(routeKey, {
|
||||
operation: "member-open-requested",
|
||||
entityId: params.personId,
|
||||
refresh: false,
|
||||
@@ -436,7 +404,7 @@ const activateExistingSinglePage = (routeKey, params, url) => {
|
||||
stack[targetIndex],
|
||||
stack[stack.length - 1],
|
||||
existingParams,
|
||||
result,
|
||||
navigationResult,
|
||||
transitionKey,
|
||||
(callbacks) => uni.navigateBack({ delta, ...callbacks }),
|
||||
);
|
||||
@@ -459,6 +427,57 @@ export const openPage = (routeKey, params = {}, sourceKey = "") =>
|
||||
return pushPage(url);
|
||||
});
|
||||
|
||||
const confirmExternalSiteContentTarget = (targetUrl) => new Promise((resolve, reject) => {
|
||||
const host = targetUrl.match(/^https:\/\/([^/?#]+)/)?.[1] || "外部网站";
|
||||
uni.showModal({
|
||||
title: "即将离开应用",
|
||||
content: `将打开外部网站 ${host},请确认链接来源可信。`,
|
||||
confirmText: "继续访问",
|
||||
cancelText: "取消",
|
||||
success: ({ confirm }) => resolve(confirm === true),
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
|
||||
export const openSiteContentTarget = async (targetUrl, onExternalFailure = null) => {
|
||||
if (
|
||||
typeof targetUrl !== "string" ||
|
||||
targetUrl.trim() !== targetUrl ||
|
||||
!(
|
||||
/^\/(?!\/)[^\s]*$/.test(targetUrl) ||
|
||||
/^https:\/\/[^\s/?#]+(?:[/?#][^\s]*)?$/.test(targetUrl)
|
||||
)
|
||||
) {
|
||||
return Promise.reject(new TypeError("站点内容跳转地址无效"));
|
||||
}
|
||||
if (onExternalFailure !== null && typeof onExternalFailure !== "function") {
|
||||
return Promise.reject(new TypeError("外部链接失败回调必须是函数"));
|
||||
}
|
||||
|
||||
if (targetUrl.startsWith("/")) {
|
||||
return runUniNavigation(`site-content:${targetUrl}`, (callbacks) =>
|
||||
uni.navigateTo({ url: targetUrl, ...callbacks }));
|
||||
}
|
||||
|
||||
if (!await confirmExternalSiteContentTarget(targetUrl)) return false;
|
||||
|
||||
if (typeof plus !== "undefined" && typeof plus.runtime?.openURL === "function") {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
plus.runtime.openURL(targetUrl, onExternalFailure || (() => {}));
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.location?.assign === "function") {
|
||||
window.location.assign(targetUrl);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.reject(new Error("当前运行环境不支持打开外部链接"));
|
||||
};
|
||||
|
||||
export const goRoot = (routeKey, params = {}) =>
|
||||
asNavigationPromise(() => {
|
||||
if (!ROOT_ROUTE_KEYS.includes(routeKey)) {
|
||||
@@ -487,7 +506,7 @@ export const openNoticeTarget = (targetType, params, sourceKey = "N02") =>
|
||||
}
|
||||
|
||||
const target = NOTICE_TARGETS[targetType];
|
||||
const normalizedParams = snapshotDataRecord("通知目标参数", params);
|
||||
const normalizedParams = copyDataRecord("通知目标参数", params);
|
||||
const expectedParams = new Set(target.params);
|
||||
for (const name of Object.keys(normalizedParams)) {
|
||||
if (!expectedParams.has(name)) {
|
||||
@@ -793,7 +812,7 @@ const resolveBackActionFromContext = ({
|
||||
};
|
||||
|
||||
const validateBackContext = (context) => {
|
||||
const normalizedContext = snapshotDataRecord("返回守卫上下文", context);
|
||||
const normalizedContext = copyDataRecord("返回守卫上下文", context);
|
||||
for (const flag of ["transientOpen", "internalTrail", "dirty", "submitting"]) {
|
||||
if (hasOwn(normalizedContext, flag) && typeof normalizedContext[flag] !== "boolean") {
|
||||
throw new TypeError(`返回守卫状态 ${flag} 必须是布尔值`);
|
||||
@@ -815,9 +834,6 @@ export const handleBackPress = (event, requestBack) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const resolveBackAction = (context = {}) =>
|
||||
resolveBackActionFromContext(validateBackContext(context));
|
||||
|
||||
export const runBackGuard = async (context = {}) => {
|
||||
const normalizedContext = validateBackContext(context);
|
||||
const action = resolveBackActionFromContext(normalizedContext);
|
||||
@@ -1,4 +1,4 @@
|
||||
// 本文件是 52 个活动页面导航语义的唯一运行时所有者。页面只能使用路由键,
|
||||
// 本文件是活动页面导航语义的唯一运行时所有者。页面只能使用路由键,
|
||||
// 不得自行复制路径、父页、允许来源、参数或流程结果规则。
|
||||
const defineRoute = (route) =>
|
||||
Object.freeze({
|
||||
@@ -16,56 +16,59 @@ const defineNoticeTarget = (routeKey, params) =>
|
||||
export const NOTICE_TARGETS = Object.freeze({
|
||||
GENEALOGY_REVIEW: defineNoticeTarget("G10", ["genealogyId"]),
|
||||
GENEALOGY_HOME: defineNoticeTarget("G01", ["genealogyId"]),
|
||||
FAMILY_FEED: defineNoticeTarget("F03", ["genealogyId", "feedId"]),
|
||||
MEMO_REMINDER: defineNoticeTarget("R10", ["genealogyId", "memoId"]),
|
||||
CEREMONY_INVITE: defineNoticeTarget("M11", []),
|
||||
});
|
||||
|
||||
export const ROUTES = Object.freeze({
|
||||
A01: defineRoute({
|
||||
path: "/pages/auth/a01-entry",
|
||||
path: "/pages/auth/sign-in",
|
||||
kind: "auth-root",
|
||||
parent: null,
|
||||
resultOperations: ["password-reset"],
|
||||
}),
|
||||
A04: defineRoute({
|
||||
path: "/pages/auth/a04-register",
|
||||
path: "/pages/auth/register",
|
||||
kind: "page",
|
||||
parent: "A01",
|
||||
allowedSources: ["A01"],
|
||||
}),
|
||||
A05: defineRoute({
|
||||
path: "/pages/auth/a05-reset-password",
|
||||
path: "/pages/auth/reset-password",
|
||||
kind: "page",
|
||||
parent: "A01",
|
||||
allowedSources: ["A01"],
|
||||
}),
|
||||
G01: defineRoute({
|
||||
path: "/pages/genealogy/g01-my-genealogies",
|
||||
path: "/pages/genealogy/my-genealogies",
|
||||
kind: "root",
|
||||
parent: null,
|
||||
optionalParams: ["genealogyId"],
|
||||
resultOperations: ["genealogy-created"],
|
||||
}),
|
||||
G03: defineRoute({
|
||||
path: "/pages/genealogy/g03-create-genealogy",
|
||||
path: "/pages/genealogy/create",
|
||||
kind: "flow",
|
||||
parent: "G01",
|
||||
allowedSources: ["G01"],
|
||||
}),
|
||||
G05: defineRoute({
|
||||
path: "/pages/genealogy/g05-genealogy-overview",
|
||||
path: "/pages/genealogy/overview",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G01", "G03", "G06", "G09"],
|
||||
}),
|
||||
G06: defineRoute({
|
||||
path: "/pages/genealogy/g06-search-genealogies",
|
||||
path: "/pages/genealogy/search",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
optionalParams: ["mode"],
|
||||
allowedSources: ["G01", "G03", "G09"],
|
||||
}),
|
||||
G08: defineRoute({
|
||||
path: "/pages/genealogy/g08-join-application",
|
||||
path: "/pages/genealogy/join-application",
|
||||
kind: "flow",
|
||||
parent: "G06",
|
||||
requiredParams: ["genealogyId"],
|
||||
@@ -73,35 +76,42 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["G01", "G05", "G06", "G09"],
|
||||
}),
|
||||
G09: defineRoute({
|
||||
path: "/pages/genealogy/g09-my-applications",
|
||||
path: "/pages/genealogy/my-applications",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
optionalParams: ["status"],
|
||||
allowedSources: ["G01", "G05", "G06", "G08"],
|
||||
}),
|
||||
G10: defineRoute({
|
||||
path: "/pages/genealogy/g10-application-review",
|
||||
path: "/pages/genealogy/application-review",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G01", "G05", "N01", "N02"],
|
||||
}),
|
||||
G11: defineRoute({
|
||||
path: "/pages/genealogy/g11-genealogy-settings",
|
||||
path: "/pages/genealogy/settings",
|
||||
kind: "flow",
|
||||
parent: "G05",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G05"],
|
||||
}),
|
||||
G12: defineRoute({
|
||||
path: "/pages/genealogy/g12-generation-poems",
|
||||
path: "/pages/genealogy/generation-poems",
|
||||
kind: "flow",
|
||||
parent: "G05",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G01", "G05"],
|
||||
}),
|
||||
G13: defineRoute({
|
||||
path: "/pages/genealogy/members",
|
||||
kind: "page",
|
||||
parent: "G05",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G05"],
|
||||
}),
|
||||
T01: defineRoute({
|
||||
path: "/pages/tree/t01-tree-overview",
|
||||
path: "/pages/tree/overview",
|
||||
kind: "page",
|
||||
parent: "G05",
|
||||
requiredParams: ["genealogyId"],
|
||||
@@ -109,7 +119,7 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["G01", "G05", "T02", "T04", "T06", "T07"],
|
||||
}),
|
||||
T02: defineRoute({
|
||||
path: "/pages/tree/t02-pedigree-overview",
|
||||
path: "/pages/tree/pedigree",
|
||||
kind: "page",
|
||||
parent: "G05",
|
||||
requiredParams: ["genealogyId"],
|
||||
@@ -117,7 +127,7 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["G01", "G05", "T01"],
|
||||
}),
|
||||
T03: defineRoute({
|
||||
path: "/pages/tree/t03-member-profile",
|
||||
path: "/pages/tree/member-profile",
|
||||
kind: "single",
|
||||
parent: "T01",
|
||||
parentParamMap: { selectedId: "personId" },
|
||||
@@ -126,7 +136,7 @@ export const ROUTES = Object.freeze({
|
||||
resultOperations: ["member-open-requested", "member-updated"],
|
||||
}),
|
||||
T04: defineRoute({
|
||||
path: "/pages/tree/t04-add-relative",
|
||||
path: "/pages/tree/add-relative",
|
||||
kind: "flow",
|
||||
parent: "T01",
|
||||
parentParamMap: { selectedId: "personId" },
|
||||
@@ -135,14 +145,14 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["T01", "T02"],
|
||||
}),
|
||||
T05: defineRoute({
|
||||
path: "/pages/tree/t05-edit-member",
|
||||
path: "/pages/tree/edit-member",
|
||||
kind: "flow",
|
||||
parent: "T03",
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["T01", "T02", "T03"],
|
||||
}),
|
||||
T06: defineRoute({
|
||||
path: "/pages/tree/t06-edit-relationship",
|
||||
path: "/pages/tree/member-rank",
|
||||
kind: "flow",
|
||||
parent: "T01",
|
||||
parentParamMap: { selectedId: "personId" },
|
||||
@@ -151,55 +161,56 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["T01", "T02"],
|
||||
}),
|
||||
T07: defineRoute({
|
||||
path: "/pages/tree/t07-member-directory",
|
||||
path: "/pages/tree/member-directory",
|
||||
kind: "page",
|
||||
parent: "T01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["T01", "T02"],
|
||||
}),
|
||||
T08: defineRoute({
|
||||
path: "/pages/tree/t08-member-states",
|
||||
path: "/pages/tree/member-states",
|
||||
kind: "page",
|
||||
parent: "T03",
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["T03"],
|
||||
}),
|
||||
F01: defineRoute({
|
||||
path: "/pages/family/f01-family-feed",
|
||||
path: "/pages/family/feed",
|
||||
kind: "root",
|
||||
parent: null,
|
||||
optionalParams: ["genealogyId"],
|
||||
}),
|
||||
F02: defineRoute({
|
||||
path: "/pages/family/f02-publish-feed",
|
||||
path: "/pages/family/feed-editor",
|
||||
kind: "flow",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
optionalParams: ["feedId"],
|
||||
allowedSources: ["F01", "F03"],
|
||||
}),
|
||||
F03: defineRoute({
|
||||
path: "/pages/family/f03-feed-detail",
|
||||
path: "/pages/family/feed-detail",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId", "feedId"],
|
||||
allowedSources: ["F01"],
|
||||
allowedSources: ["F01", "F02", "N02"],
|
||||
}),
|
||||
F04: defineRoute({
|
||||
path: "/pages/family/f04-article-list",
|
||||
path: "/pages/family/articles",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
F05: defineRoute({
|
||||
path: "/pages/family/f05-article-detail",
|
||||
path: "/pages/family/article-detail",
|
||||
kind: "page",
|
||||
parent: "F04",
|
||||
requiredParams: ["genealogyId", "articleId"],
|
||||
allowedSources: ["F04"],
|
||||
}),
|
||||
F06: defineRoute({
|
||||
path: "/pages/family/f06-article-editor",
|
||||
path: "/pages/family/article-editor",
|
||||
kind: "flow",
|
||||
parent: "F04",
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
@@ -207,42 +218,42 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["F04", "F05"],
|
||||
}),
|
||||
F07: defineRoute({
|
||||
path: "/pages/family/f07-album-list",
|
||||
path: "/pages/family/albums",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
F08: defineRoute({
|
||||
path: "/pages/family/f08-album-detail",
|
||||
path: "/pages/family/album-detail",
|
||||
kind: "page",
|
||||
parent: "F07",
|
||||
requiredParams: ["genealogyId", "albumId"],
|
||||
allowedSources: ["F07"],
|
||||
}),
|
||||
F09: defineRoute({
|
||||
path: "/pages/family/f09-media-upload",
|
||||
path: "/pages/family/add-photo",
|
||||
kind: "flow",
|
||||
parent: "F08",
|
||||
requiredParams: ["genealogyId", "albumId"],
|
||||
allowedSources: ["F08"],
|
||||
}),
|
||||
F10: defineRoute({
|
||||
path: "/pages/family/f10-video-list",
|
||||
path: "/pages/family/videos",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R01: defineRoute({
|
||||
path: "/pages/records/r01-people-list",
|
||||
path: "/pages/records/people",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R02: defineRoute({
|
||||
path: "/pages/records/r02-person-detail",
|
||||
path: "/pages/records/person-detail",
|
||||
kind: "flow",
|
||||
parent: "R01",
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
@@ -250,14 +261,14 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["R01"],
|
||||
}),
|
||||
R03: defineRoute({
|
||||
path: "/pages/records/r03-gift-list",
|
||||
path: "/pages/records/relative-records",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R04: defineRoute({
|
||||
path: "/pages/records/r04-gift-editor",
|
||||
path: "/pages/records/relative-record-editor",
|
||||
kind: "flow",
|
||||
parent: "R03",
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
@@ -265,21 +276,21 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["R03"],
|
||||
}),
|
||||
R05: defineRoute({
|
||||
path: "/pages/records/r05-ritual-list",
|
||||
path: "/pages/records/ceremonies",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R06: defineRoute({
|
||||
path: "/pages/records/r06-ritual-detail",
|
||||
path: "/pages/records/ceremony-detail",
|
||||
kind: "page",
|
||||
parent: "R05",
|
||||
requiredParams: ["genealogyId", "ceremonyId"],
|
||||
allowedSources: ["R05"],
|
||||
}),
|
||||
R07: defineRoute({
|
||||
path: "/pages/records/r07-ritual-editor",
|
||||
path: "/pages/records/ceremony-editor",
|
||||
kind: "flow",
|
||||
parent: "R05",
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
@@ -287,106 +298,126 @@ export const ROUTES = Object.freeze({
|
||||
allowedSources: ["R05", "R06"],
|
||||
}),
|
||||
R08: defineRoute({
|
||||
path: "/pages/records/r08-growth-journal",
|
||||
path: "/pages/records/growth-journal",
|
||||
kind: "page",
|
||||
parent: "R02",
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["R02", "T03"],
|
||||
}),
|
||||
R09: defineRoute({
|
||||
path: "/pages/records/r09-life-events",
|
||||
path: "/pages/records/life-events",
|
||||
kind: "page",
|
||||
parent: "R02",
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["R02", "T03"],
|
||||
}),
|
||||
R10: defineRoute({
|
||||
path: "/pages/records/r10-memo-list",
|
||||
path: "/pages/records/memos",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
optionalParams: ["memoId"],
|
||||
allowedSources: ["F01", "N02"],
|
||||
}),
|
||||
R11: defineRoute({
|
||||
path: "/pages/records/r11-merit-records",
|
||||
path: "/pages/records/merit-records",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
N01: defineRoute({
|
||||
path: "/pages/notification/n01-message-center",
|
||||
path: "/pages/notification/message-center",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
optionalParams: ["genealogyId"],
|
||||
allowedSources: ["G01", "M01"],
|
||||
}),
|
||||
N02: defineRoute({
|
||||
path: "/pages/notification/n02-message-detail",
|
||||
path: "/pages/notification/message-detail",
|
||||
kind: "page",
|
||||
parent: "N01",
|
||||
requiredParams: ["id"],
|
||||
allowedSources: ["N01"],
|
||||
}),
|
||||
M01: defineRoute({
|
||||
path: "/pages/profile/m01-profile-home",
|
||||
path: "/pages/profile/home",
|
||||
kind: "root",
|
||||
parent: null,
|
||||
}),
|
||||
M02: defineRoute({
|
||||
path: "/pages/profile/m02-edit-profile",
|
||||
path: "/pages/profile/edit-profile",
|
||||
kind: "flow",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M03: defineRoute({
|
||||
path: "/pages/profile/m03-security-settings",
|
||||
path: "/pages/profile/security",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M04: defineRoute({
|
||||
path: "/pages/profile/m04-change-password",
|
||||
path: "/pages/profile/change-password",
|
||||
kind: "flow",
|
||||
parent: "M03",
|
||||
allowedSources: ["M03"],
|
||||
}),
|
||||
M05: defineRoute({
|
||||
path: "/pages/profile/m05-change-phone",
|
||||
path: "/pages/profile/change-phone",
|
||||
kind: "flow",
|
||||
parent: "M03",
|
||||
allowedSources: ["M03"],
|
||||
}),
|
||||
M06: defineRoute({
|
||||
path: "/pages/profile/m06-help-center",
|
||||
path: "/pages/profile/help",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M07: defineRoute({
|
||||
path: "/pages/profile/m07-feedback",
|
||||
path: "/pages/profile/feedback",
|
||||
kind: "flow",
|
||||
parent: "M06",
|
||||
allowedSources: ["M01", "M06"],
|
||||
}),
|
||||
M08: defineRoute({
|
||||
path: "/pages/profile/m08-promotion",
|
||||
path: "/pages/profile/promotions",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M09: defineRoute({
|
||||
path: "/pages/profile/m09-vip-orders",
|
||||
path: "/pages/profile/vip",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M10: defineRoute({
|
||||
path: "/pages/profile/m10-about-settings",
|
||||
path: "/pages/profile/settings",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M11: defineRoute({
|
||||
path: "/pages/profile/ceremony-invitations",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01", "N02"],
|
||||
}),
|
||||
M12: defineRoute({
|
||||
path: "/pages/profile/earnings",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M13: defineRoute({
|
||||
path: "/pages/profile/compliance-document",
|
||||
kind: "page",
|
||||
parent: "M10",
|
||||
requiredParams: ["documentKey"],
|
||||
allowedSources: ["M10"],
|
||||
}),
|
||||
});
|
||||
|
||||
export const ROOT_ROUTE_KEYS = Object.freeze(["A01", "G01", "F01", "M01"]);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { isWriteOutcomeUnknown } from '@/utils/request-outcome.js'
|
||||
|
||||
export const createNonIdempotentWriteGuard = () => {
|
||||
let uncertainPayloadSnapshot = ''
|
||||
|
||||
return {
|
||||
begin(payload) {
|
||||
const payloadSnapshot = JSON.stringify(payload)
|
||||
return payloadSnapshot === uncertainPayloadSnapshot ? null : payloadSnapshot
|
||||
},
|
||||
|
||||
recordFailure(payloadSnapshot, error) {
|
||||
if (!isWriteOutcomeUnknown(error)) return false
|
||||
uncertainPayloadSnapshot = payloadSnapshot
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const markdownHeading = /^(#{1,6})\s+(.+)$/;
|
||||
const orderedListItem = /^(\d+)[.、]\s*(.+)$/;
|
||||
const unorderedListItem = /^[-*]\s+(.+)$/;
|
||||
|
||||
export const formatComplianceContent = (value, documentTitle = "") => {
|
||||
if (typeof value !== "string" || !value.trim()) return [];
|
||||
|
||||
const blocks = value
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const heading = line.match(markdownHeading);
|
||||
if (heading) {
|
||||
return {
|
||||
type: heading[1].length === 1 ? "title" : "heading",
|
||||
text: heading[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const ordered = line.match(orderedListItem);
|
||||
if (ordered) {
|
||||
return {
|
||||
type: "list-item",
|
||||
marker: `${ordered[1]}.`,
|
||||
text: ordered[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const unordered = line.match(unorderedListItem);
|
||||
if (unordered) {
|
||||
return { type: "list-item", marker: "•", text: unordered[1].trim() };
|
||||
}
|
||||
|
||||
return { type: "paragraph", text: line };
|
||||
});
|
||||
|
||||
const duplicateTitle =
|
||||
blocks[0]?.type === "title" &&
|
||||
blocks[0].text === String(documentTitle || "").trim();
|
||||
if (!duplicateTitle) return blocks;
|
||||
|
||||
let contentBlocks = blocks.slice(1);
|
||||
while (
|
||||
contentBlocks[0]?.type === "paragraph" &&
|
||||
/^(?:版本号|版本|发布日期|生效日期)\s*[::]/.test(contentBlocks[0].text)
|
||||
) {
|
||||
contentBlocks = contentBlocks.slice(1);
|
||||
}
|
||||
return contentBlocks;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export const parseMoneyToCents = (amount) => {
|
||||
const amountParts = String(amount || "").match(/^(\d+)(?:\.(\d{1,2}))?$/);
|
||||
if (!amountParts) return null;
|
||||
return (
|
||||
BigInt(amountParts[1]) * 100n +
|
||||
BigInt((amountParts[2] || "").padEnd(2, "0"))
|
||||
);
|
||||
};
|
||||
|
||||
export const formatSignedMoney = (amount) => {
|
||||
const normalizedAmount = String(amount);
|
||||
const sign = normalizedAmount.startsWith("-") ? "-" : "+";
|
||||
return `${sign}¥${normalizedAmount.replace(/^-/, "")}`;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { isWriteOutcomeUnknown } from '@/utils/request-outcome.js'
|
||||
|
||||
const messages = {
|
||||
submitted:
|
||||
"反馈已提交。以下内容为本次提交记录;修改任一项后可提交新反馈。",
|
||||
submittedWithEdits: "上一份反馈已提交,当前修改尚未提交。",
|
||||
uncertain:
|
||||
"暂时无法确认是否提交成功,请不要重复提交相同内容。你可以修改内容后再提交一份反馈。",
|
||||
uncertainWithEdits: "上一份反馈暂未确认,当前修改尚未提交。",
|
||||
};
|
||||
|
||||
const normalizeFeedback = (form) => ({
|
||||
feedbackType: String(form?.feedbackType || "").trim(),
|
||||
feedbackContent: String(form?.feedbackContent || "").trim(),
|
||||
contactInfo: String(form?.contactInfo || "").trim(),
|
||||
});
|
||||
|
||||
// 字段顺序固定的快照既用于脏数据判断,也用于阻止非幂等 POST 重复提交。
|
||||
// 不能改成对象引用比较,否则 Vue 表单原地修改时无法识别同一份内容。
|
||||
const snapshotOf = (form) => JSON.stringify(normalizeFeedback(form));
|
||||
|
||||
const errorMessage = (error) => {
|
||||
if (error?.code === "WRITE_UNAVAILABLE") {
|
||||
return "当前为本地预览,反馈没有发出。";
|
||||
}
|
||||
if (error?.httpStatus === 401) {
|
||||
return "登录状态已失效,请重新登录后再提交。";
|
||||
}
|
||||
return "提交未完成,请稍后再试。";
|
||||
};
|
||||
|
||||
const submitLabelFor = (phase) => {
|
||||
if (phase === "submitting") return "正在提交";
|
||||
if (phase === "success") return "反馈已提交";
|
||||
if (phase === "uncertain") return "暂未确认";
|
||||
if (phase === "error") return "重新提交";
|
||||
return "提交反馈";
|
||||
};
|
||||
|
||||
export const createFeedbackSubmissionSession = (initialForm) => {
|
||||
let baselineSnapshot = snapshotOf(initialForm);
|
||||
let lastSubmittedSnapshot = "";
|
||||
let lastUncertainSnapshot = "";
|
||||
let phase = "ready";
|
||||
let message = "";
|
||||
let tone = "";
|
||||
|
||||
const view = (currentForm) => {
|
||||
const currentSnapshot = snapshotOf(currentForm);
|
||||
const isRepeatedSubmission =
|
||||
currentSnapshot === lastSubmittedSnapshot ||
|
||||
currentSnapshot === lastUncertainSnapshot;
|
||||
return {
|
||||
phase,
|
||||
message,
|
||||
tone,
|
||||
isDirty: currentSnapshot !== baselineSnapshot,
|
||||
isSubmitDisabled: phase === "submitting" || isRepeatedSubmission,
|
||||
submitLabel: submitLabelFor(phase),
|
||||
};
|
||||
};
|
||||
|
||||
const begin = (currentForm) => {
|
||||
const currentSnapshot = snapshotOf(currentForm);
|
||||
if (
|
||||
phase === "submitting" ||
|
||||
currentSnapshot === lastSubmittedSnapshot ||
|
||||
currentSnapshot === lastUncertainSnapshot
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
phase = "submitting";
|
||||
message = "";
|
||||
tone = "";
|
||||
return {
|
||||
snapshot: currentSnapshot,
|
||||
payload: normalizeFeedback(currentForm),
|
||||
};
|
||||
};
|
||||
|
||||
const succeed = (submission, currentForm) => {
|
||||
baselineSnapshot = submission.snapshot;
|
||||
lastSubmittedSnapshot = submission.snapshot;
|
||||
tone = "success";
|
||||
if (snapshotOf(currentForm) === submission.snapshot) {
|
||||
phase = "success";
|
||||
message = messages.submitted;
|
||||
} else {
|
||||
phase = "ready";
|
||||
message = messages.submittedWithEdits;
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (submission, error, currentForm) => {
|
||||
if (!isWriteOutcomeUnknown(error)) {
|
||||
phase = "error";
|
||||
message = errorMessage(error);
|
||||
tone = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
// 超时或异常 2xx 可能已经被后端写入。记录原快照并禁用原样重试,
|
||||
// 避免用户在网络恢复后生成两条内容完全相同的反馈。
|
||||
lastUncertainSnapshot = submission.snapshot;
|
||||
tone = "uncertain";
|
||||
if (snapshotOf(currentForm) === submission.snapshot) {
|
||||
phase = "uncertain";
|
||||
message = messages.uncertain;
|
||||
} else {
|
||||
phase = "ready";
|
||||
message = messages.uncertainWithEdits;
|
||||
}
|
||||
};
|
||||
|
||||
const reconcile = (currentForm) => {
|
||||
if (phase === "submitting") return;
|
||||
const currentSnapshot = snapshotOf(currentForm);
|
||||
if (lastSubmittedSnapshot && currentSnapshot === lastSubmittedSnapshot) {
|
||||
phase = "success";
|
||||
message = messages.submitted;
|
||||
tone = "success";
|
||||
return;
|
||||
}
|
||||
if (lastUncertainSnapshot && currentSnapshot === lastUncertainSnapshot) {
|
||||
phase = "uncertain";
|
||||
message = messages.uncertain;
|
||||
tone = "uncertain";
|
||||
return;
|
||||
}
|
||||
phase = "ready";
|
||||
if (lastUncertainSnapshot) {
|
||||
message = messages.uncertainWithEdits;
|
||||
tone = "uncertain";
|
||||
} else if (lastSubmittedSnapshot) {
|
||||
message = messages.submittedWithEdits;
|
||||
tone = "success";
|
||||
} else {
|
||||
message = "";
|
||||
tone = "";
|
||||
}
|
||||
};
|
||||
|
||||
return { begin, succeed, fail, reconcile, view };
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export const isWriteOutcomeUnknown = (error) => {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
if (
|
||||
error.code === 'REQUEST_TIMEOUT' ||
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'RESPONSE_INVALID' ||
|
||||
error.code === 'REQUEST_CANCELLED'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (error.code !== 'HTTP_ERROR' && error.code !== 'BUSINESS_ERROR') return false
|
||||
|
||||
const status = Number(
|
||||
error.code === 'HTTP_ERROR' ? error.httpStatus : error.businessCode
|
||||
)
|
||||
if (!Number.isInteger(status)) return true
|
||||
return !(status >= 400 && status < 500 && status !== 408)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
const configuredMode = import.meta.env.VITE_JIAPU_RUNTIME_MODE || 'remote'
|
||||
const configuredBaseUrl = import.meta.env.VITE_JIAPU_API_BASE_URL || 'https://backend-api.ddxcjp.cn'
|
||||
const configuredClientId = import.meta.env.VITE_JIAPU_CLIENT_ID || '428a8310cd442757ae699df5d894f051'
|
||||
const configuredTenantId = import.meta.env.VITE_JIAPU_TENANT_ID || '000000'
|
||||
|
||||
if (!['mock', 'remote'].includes(configuredMode)) {
|
||||
throw new Error('VITE_JIAPU_RUNTIME_MODE 只允许 mock 或 remote')
|
||||
}
|
||||
if (configuredMode === 'remote' && !/^https:\/\/[^\s/]+(?:\/[^\s]*)?$/.test(configuredBaseUrl)) {
|
||||
throw new Error('VITE_JIAPU_API_BASE_URL 必须是 HTTPS 地址')
|
||||
}
|
||||
if (configuredBaseUrl.endsWith('/')) {
|
||||
throw new Error('VITE_JIAPU_API_BASE_URL 不能以斜杠结尾')
|
||||
}
|
||||
if (!configuredClientId || !configuredTenantId) {
|
||||
throw new Error('远程服务的 clientId 和 tenantId 不能为空')
|
||||
}
|
||||
|
||||
// 业务模块只消费这一个不可变配置;不同构建环境通过 VITE_JIAPU_* 覆盖。
|
||||
export const runtimeConfig = Object.freeze({
|
||||
mode: configuredMode,
|
||||
baseUrl: configuredMode === 'remote' ? configuredBaseUrl : '',
|
||||
clientId: configuredClientId,
|
||||
tenantId: configuredTenantId
|
||||
})
|
||||
|
||||
export const isMockMode = () => runtimeConfig.mode === 'mock'
|
||||
export const hasRemoteConfig = () => runtimeConfig.mode === 'remote' && Boolean(runtimeConfig.baseUrl && runtimeConfig.clientId)
|
||||
|
||||
export const resolveRuntimeMode = () => {
|
||||
if (isMockMode()) return 'mock'
|
||||
if (hasRemoteConfig()) return 'remote'
|
||||
throw new Error('运行模式配置无效:只允许 mock 或配置完整的 remote')
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { genealogyContext } from "./genealogy-context.js";
|
||||
import { genealogyContext } from "./genealogy/context.js";
|
||||
|
||||
const TOKEN_KEY = 'jiapu_token'
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
const GRID_UNIT = 5;
|
||||
const NODE_HALF_WIDTH = 80;
|
||||
const NODE_HALF_HEIGHT = 112;
|
||||
const FAMILY_LINK_OFFSET = 18;
|
||||
const MEMBER_GAP = 178;
|
||||
const GENERATION_GAP = 296;
|
||||
|
||||
const snapToGrid = (coordinate) =>
|
||||
Math.ceil(coordinate / GRID_UNIT) * GRID_UNIT;
|
||||
|
||||
const compareMembers = (leftMember, rightMember) =>
|
||||
Number(leftMember.generation) - Number(rightMember.generation) ||
|
||||
String(leftMember.id).localeCompare(String(rightMember.id));
|
||||
|
||||
const positionMembers = (members) => {
|
||||
const memberById = new Map(
|
||||
members.map((member) => [String(member.id), member]),
|
||||
);
|
||||
const spousesByPersonId = new Map();
|
||||
members.forEach((member) => {
|
||||
const partnerId = String(member.spouseOf || "");
|
||||
if (partnerId && memberById.has(partnerId)) {
|
||||
spousesByPersonId.set(partnerId, member);
|
||||
}
|
||||
});
|
||||
|
||||
const primaryMembers = members
|
||||
.filter((member) => !member.spouseOf)
|
||||
.sort(compareMembers);
|
||||
const primaryById = new Map(
|
||||
primaryMembers.map((member) => [String(member.id), member]),
|
||||
);
|
||||
const childrenByParent = new Map();
|
||||
primaryMembers.forEach((member) => {
|
||||
const parentId = String(member.parentId || "");
|
||||
if (!parentId || !primaryById.has(parentId)) return;
|
||||
const children = childrenByParent.get(parentId) || [];
|
||||
children.push(member);
|
||||
childrenByParent.set(parentId, children);
|
||||
});
|
||||
childrenByParent.forEach((children) => children.sort(compareMembers));
|
||||
|
||||
// 每个家庭独占一段连续横向空间,父辈始终位于该空间中央。
|
||||
// 不能按世代逐行居中,否则后代较多的支系会被拉到另一支系下方。
|
||||
const subtreeWidthById = new Map();
|
||||
const measureSubtree = (member, ancestorIds = new Set()) => {
|
||||
const memberId = String(member.id);
|
||||
if (subtreeWidthById.has(memberId)) {
|
||||
return subtreeWidthById.get(memberId);
|
||||
}
|
||||
if (ancestorIds.has(memberId)) return 1;
|
||||
|
||||
const nextAncestorIds = new Set(ancestorIds);
|
||||
nextAncestorIds.add(memberId);
|
||||
const children = childrenByParent.get(memberId) || [];
|
||||
const descendantsWidth = children.reduce(
|
||||
(totalWidth, child) =>
|
||||
totalWidth + measureSubtree(child, nextAncestorIds),
|
||||
0,
|
||||
);
|
||||
const ownWidth = spousesByPersonId.has(memberId) ? 2 : 1;
|
||||
const subtreeWidth = Math.max(ownWidth, descendantsWidth || 1);
|
||||
subtreeWidthById.set(memberId, subtreeWidth);
|
||||
return subtreeWidth;
|
||||
};
|
||||
|
||||
const rootMembers = primaryMembers.filter(
|
||||
(member) => !primaryById.has(String(member.parentId || "")),
|
||||
);
|
||||
rootMembers.forEach((rootMember) => measureSubtree(rootMember));
|
||||
|
||||
const occupiedWidth = rootMembers.reduce(
|
||||
(totalWidth, rootMember) =>
|
||||
totalWidth + (subtreeWidthById.get(String(rootMember.id)) || 1),
|
||||
0,
|
||||
);
|
||||
const generations = Array.from(
|
||||
new Set(primaryMembers.map((member) => Number(member.generation))),
|
||||
).sort((leftGeneration, rightGeneration) =>
|
||||
leftGeneration - rightGeneration,
|
||||
);
|
||||
const generationIndexByNumber = new Map(
|
||||
generations.map((generation, index) => [generation, index]),
|
||||
);
|
||||
const positionedMembers = [];
|
||||
const positionedMemberIds = new Set();
|
||||
|
||||
const placeSubtree = (
|
||||
member,
|
||||
startUnit,
|
||||
subtreeWidth,
|
||||
ancestorIds = new Set(),
|
||||
) => {
|
||||
const memberId = String(member.id);
|
||||
if (ancestorIds.has(memberId)) return;
|
||||
|
||||
const nextAncestorIds = new Set(ancestorIds);
|
||||
nextAncestorIds.add(memberId);
|
||||
const spouse = spousesByPersonId.get(memberId);
|
||||
const centerUnit = startUnit + subtreeWidth / 2;
|
||||
const memberUnitX = spouse ? centerUnit - 0.5 : centerUnit;
|
||||
const y = snapToGrid(
|
||||
200 +
|
||||
(generationIndexByNumber.get(Number(member.generation)) || 0) *
|
||||
GENERATION_GAP,
|
||||
);
|
||||
positionedMembers.push({
|
||||
...member,
|
||||
x: snapToGrid(80 + memberUnitX * MEMBER_GAP),
|
||||
y,
|
||||
});
|
||||
positionedMemberIds.add(memberId);
|
||||
|
||||
if (spouse) {
|
||||
positionedMembers.push({
|
||||
...spouse,
|
||||
x: snapToGrid(80 + (memberUnitX + 1) * MEMBER_GAP),
|
||||
y,
|
||||
});
|
||||
positionedMemberIds.add(String(spouse.id));
|
||||
}
|
||||
|
||||
const children = childrenByParent.get(memberId) || [];
|
||||
const childrenWidth = children.reduce(
|
||||
(totalWidth, child) =>
|
||||
totalWidth + (subtreeWidthById.get(String(child.id)) || 1),
|
||||
0,
|
||||
);
|
||||
let childStartUnit = startUnit + (subtreeWidth - childrenWidth) / 2;
|
||||
children.forEach((child) => {
|
||||
const childWidth = subtreeWidthById.get(String(child.id)) || 1;
|
||||
placeSubtree(child, childStartUnit, childWidth, nextAncestorIds);
|
||||
childStartUnit += childWidth;
|
||||
});
|
||||
};
|
||||
|
||||
let rootStartUnit = 0;
|
||||
rootMembers.forEach((rootMember) => {
|
||||
const rootWidth = subtreeWidthById.get(String(rootMember.id)) || 1;
|
||||
placeSubtree(rootMember, rootStartUnit, rootWidth);
|
||||
rootStartUnit += rootWidth;
|
||||
});
|
||||
|
||||
members
|
||||
.filter((member) => !positionedMemberIds.has(String(member.id)))
|
||||
.forEach((member, index) => {
|
||||
positionedMembers.push({
|
||||
...member,
|
||||
x: snapToGrid(80 + (occupiedWidth + index + 0.5) * MEMBER_GAP),
|
||||
y: snapToGrid(
|
||||
200 +
|
||||
(generationIndexByNumber.get(Number(member.generation)) || 0) *
|
||||
GENERATION_GAP,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
return positionedMembers;
|
||||
};
|
||||
|
||||
const createMetrics = (positionedMembers) => {
|
||||
const maxX = Math.max(0, ...positionedMembers.map((member) => member.x));
|
||||
const maxY = Math.max(0, ...positionedMembers.map((member) => member.y));
|
||||
const width = Math.max(660, snapToGrid(maxX + NODE_HALF_WIDTH + 50));
|
||||
const height = Math.max(640, snapToGrid(maxY + 250));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
gridUnit: GRID_UNIT,
|
||||
columns: width / GRID_UNIT,
|
||||
rows: height / GRID_UNIT,
|
||||
};
|
||||
};
|
||||
|
||||
const createGenerationRows = (positionedMembers) => {
|
||||
const membersByGeneration = new Map();
|
||||
positionedMembers.forEach((member) => {
|
||||
const generationMembers = membersByGeneration.get(member.generation) || [];
|
||||
generationMembers.push(member);
|
||||
membersByGeneration.set(member.generation, generationMembers);
|
||||
});
|
||||
|
||||
return Array.from(membersByGeneration.entries())
|
||||
.sort(([leftGeneration], [rightGeneration]) =>
|
||||
leftGeneration - rightGeneration,
|
||||
)
|
||||
.map(([generation, generationMembers]) => {
|
||||
const y = Math.min(...generationMembers.map((member) => member.y));
|
||||
return {
|
||||
generation,
|
||||
label: `第 ${generation} 世`,
|
||||
summary: `${generationMembers.length} 位成员`,
|
||||
y,
|
||||
bandStyle: {
|
||||
gridRow: `${Math.max(1, Math.round((y - NODE_HALF_HEIGHT) / GRID_UNIT) + 1)} / span 21`,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createConnectors = (positionedMembers) => {
|
||||
const memberById = new Map(
|
||||
positionedMembers.map((member) => [String(member.id), member]),
|
||||
);
|
||||
const spouseByPersonId = new Map();
|
||||
positionedMembers.forEach((member) => {
|
||||
const partnerId = String(member.spouseOf || "");
|
||||
if (partnerId && memberById.has(partnerId)) {
|
||||
spouseByPersonId.set(partnerId, member);
|
||||
}
|
||||
});
|
||||
|
||||
const childrenByParent = new Map();
|
||||
positionedMembers.forEach((member) => {
|
||||
const parentId = String(member.parentId || "");
|
||||
if (member.spouseOf || !parentId || !memberById.has(parentId)) return;
|
||||
const children = childrenByParent.get(parentId) || [];
|
||||
children.push(member);
|
||||
childrenByParent.set(parentId, children);
|
||||
});
|
||||
|
||||
const verticalStyle = (x, top, bottom) => ({
|
||||
gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 1`,
|
||||
gridRow: `${Math.round(top / GRID_UNIT) + 1} / ${Math.round(bottom / GRID_UNIT) + 1}`,
|
||||
});
|
||||
const horizontalStyle = (left, right, y) => ({
|
||||
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
|
||||
gridRow: `${Math.round(y / GRID_UNIT) + 1} / span 1`,
|
||||
});
|
||||
const connectors = [];
|
||||
|
||||
childrenByParent.forEach((children, parentId) => {
|
||||
const parent = memberById.get(parentId);
|
||||
const spouse = spouseByPersonId.get(parentId);
|
||||
const parentAnchorX = spouse
|
||||
? snapToGrid((parent.x + spouse.x) / 2)
|
||||
: parent.x;
|
||||
const parentBottom = parent.y + NODE_HALF_HEIGHT;
|
||||
const parentAnchorY = spouse
|
||||
? snapToGrid(parentBottom + FAMILY_LINK_OFFSET)
|
||||
: parentBottom;
|
||||
const childTop = Math.min(
|
||||
...children.map((child) => child.y - NODE_HALF_HEIGHT),
|
||||
);
|
||||
const branchY = snapToGrid((parentAnchorY + childTop) / 2);
|
||||
const minChildX = Math.min(...children.map((child) => child.x));
|
||||
const maxChildX = Math.max(...children.map((child) => child.x));
|
||||
|
||||
if (spouse) {
|
||||
connectors.push({
|
||||
id: `${parentId}-${spouse.id}-family-parent`,
|
||||
kind: "family",
|
||||
style: verticalStyle(parent.x, parentBottom, parentAnchorY),
|
||||
});
|
||||
connectors.push({
|
||||
id: `${parentId}-${spouse.id}-family-spouse`,
|
||||
kind: "family",
|
||||
style: verticalStyle(
|
||||
spouse.x,
|
||||
spouse.y + NODE_HALF_HEIGHT,
|
||||
parentAnchorY,
|
||||
),
|
||||
});
|
||||
connectors.push({
|
||||
id: `${parentId}-${spouse.id}-family-bridge`,
|
||||
kind: "family",
|
||||
style: horizontalStyle(
|
||||
Math.min(parent.x, spouse.x),
|
||||
Math.max(parent.x, spouse.x),
|
||||
parentAnchorY,
|
||||
),
|
||||
});
|
||||
}
|
||||
connectors.push({
|
||||
id: `${parentId}-trunk`,
|
||||
style: verticalStyle(parentAnchorX, parentAnchorY, branchY),
|
||||
});
|
||||
connectors.push({
|
||||
id: `${parentId}-branch`,
|
||||
style: horizontalStyle(minChildX, maxChildX, branchY),
|
||||
});
|
||||
children.forEach((child) => {
|
||||
connectors.push({
|
||||
id: `${parentId}-${child.id}`,
|
||||
style: verticalStyle(child.x, branchY, child.y - NODE_HALF_HEIGHT),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
positionedMembers.forEach((member) => {
|
||||
if (!member.spouseOf) return;
|
||||
const partner = memberById.get(String(member.spouseOf));
|
||||
if (!partner || partner.generation !== member.generation) return;
|
||||
if (childrenByParent.has(String(partner.id))) return;
|
||||
const left = Math.min(member.x, partner.x) + NODE_HALF_WIDTH;
|
||||
const right = Math.max(member.x, partner.x) - NODE_HALF_WIDTH;
|
||||
if (right <= left) return;
|
||||
connectors.push({
|
||||
id: `${partner.id}-${member.id}-spouse`,
|
||||
kind: "spouse",
|
||||
style: horizontalStyle(left, right, member.y),
|
||||
});
|
||||
});
|
||||
|
||||
return connectors;
|
||||
};
|
||||
|
||||
// 这是世系图布局的唯一公开入口。页面只消费坐标、尺寸、世代行和连线,
|
||||
// 不需要了解支系宽度测量、循环关系保护或配偶桥接规则。
|
||||
export const createTreeLayout = (members) => {
|
||||
const positionedMembers = positionMembers(Array.isArray(members) ? members : []);
|
||||
return {
|
||||
members: positionedMembers,
|
||||
metrics: createMetrics(positionedMembers),
|
||||
generationRows: createGenerationRows(positionedMembers),
|
||||
connectors: createConnectors(positionedMembers),
|
||||
};
|
||||
};
|
||||
|
||||
export const treeNodeGridStyle = (member) => ({
|
||||
gridColumn: `${member.x / GRID_UNIT + 1}`,
|
||||
gridRow: `${member.y / GRID_UNIT + 1}`,
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
LINEAGE_PERSON_OPTIONS,
|
||||
LINEAGE_RELATION_OPTIONS,
|
||||
LINEAGE_RELATION_TYPE
|
||||
} from '@/services/api/lineage-person-options.js'
|
||||
|
||||
export const memberFormOptions = LINEAGE_PERSON_OPTIONS
|
||||
export const memberRelationOptions = LINEAGE_RELATION_OPTIONS
|
||||
export const memberRelationTypes = LINEAGE_RELATION_TYPE
|
||||
|
||||
export const findMemberOptionIndex = (options, value) => Math.max(
|
||||
0,
|
||||
options.findIndex((option) => option.value === value)
|
||||
)
|
||||
|
||||
export const findMemberOptionLabel = (options, value) => (
|
||||
options.find((option) => option.value === value)?.label || ''
|
||||
)
|
||||
|
||||
const pickerOptionValue = (options, event, fallback = '') => (
|
||||
options[Number(event.detail.value)]?.value || fallback
|
||||
)
|
||||
|
||||
export const updateMemberFormOption = (form, field, options, event) => {
|
||||
form[field] = pickerOptionValue(options, event)
|
||||
if (field === 'personStatus' && form.personStatus !== '1') {
|
||||
Object.assign(form, {
|
||||
deathDate: '',
|
||||
deathLunar: '',
|
||||
deathPlace: '',
|
||||
burialPlace: ''
|
||||
})
|
||||
}
|
||||
return form[field]
|
||||
}
|
||||
|
||||
export const updateMemberBindingMode = (form, event) => {
|
||||
form.bindingMode = pickerOptionValue(memberFormOptions.bindingMode, event, 'NONE')
|
||||
if (form.bindingMode !== 'SPECIFIED') form.appUserId = ''
|
||||
return form.bindingMode
|
||||
}
|
||||
Reference in New Issue
Block a user