init project
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="body-selector" :class="{ readonly: props.readonly }">
|
||||
<svg
|
||||
viewBox="0 0 240 500"
|
||||
class="figure"
|
||||
role="group"
|
||||
aria-label="正面人体调理部位选择"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g class="face-guide" aria-hidden="true">
|
||||
<circle cx="110" cy="56" r="3.5" />
|
||||
<circle cx="130" cy="56" r="3.5" />
|
||||
<path d="M120 62L116 72H124" />
|
||||
<path d="M108 80C113 85 127 85 132 80" />
|
||||
</g>
|
||||
<g
|
||||
v-for="area in areas"
|
||||
:key="area.key"
|
||||
class="area"
|
||||
:class="{ active: isSelected(area.key) }"
|
||||
role="checkbox"
|
||||
:tabindex="props.readonly ? -1 : 0"
|
||||
:aria-label="area.label"
|
||||
:aria-checked="isSelected(area.key)"
|
||||
@click="toggle(area.key)"
|
||||
@keydown.enter.prevent="toggle(area.key)"
|
||||
@keydown.space.prevent="toggle(area.key)"
|
||||
>
|
||||
<title v-text="area.label" />
|
||||
<ellipse
|
||||
v-if="area.shape === 'ellipse'"
|
||||
:cx="area.cx"
|
||||
:cy="area.cy"
|
||||
:rx="area.rx"
|
||||
:ry="area.ry"
|
||||
/>
|
||||
<rect
|
||||
v-else-if="area.shape === 'rect'"
|
||||
:x="area.x"
|
||||
:y="area.y"
|
||||
:width="area.width"
|
||||
:height="area.height"
|
||||
:rx="area.radius"
|
||||
/>
|
||||
<path v-else :d="area.path" />
|
||||
</g>
|
||||
</svg>
|
||||
<p class="selected" v-text="selectedText" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface BodyArea {
|
||||
key: string;
|
||||
label: string;
|
||||
shape: "ellipse" | "rect" | "path";
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
rx?: number;
|
||||
ry?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
radius?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: {
|
||||
selectedCodeList: string[];
|
||||
};
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: { selectedCodeList: string[] }): void;
|
||||
}>();
|
||||
|
||||
const areas: BodyArea[] = [
|
||||
{ key: "head", label: "头部", shape: "ellipse", cx: 120, cy: 62, rx: 31, ry: 35 },
|
||||
{ key: "neck", label: "颈部", shape: "rect", x: 108, y: 96, width: 24, height: 24, radius: 9 },
|
||||
{ key: "left-shoulder", label: "右肩", shape: "ellipse", cx: 86, cy: 135, rx: 25, ry: 19 },
|
||||
{ key: "right-shoulder", label: "左肩", shape: "ellipse", cx: 154, cy: 135, rx: 25, ry: 19 },
|
||||
{
|
||||
key: "left-upper-arm",
|
||||
label: "右上臂",
|
||||
shape: "path",
|
||||
path: "M69 128C59 130 52 136 49 147L31 216C30 224 34 230 41 232C49 234 54 229 56 222L79 157C84 143 81 132 69 128Z"
|
||||
},
|
||||
{
|
||||
key: "right-upper-arm",
|
||||
label: "左上臂",
|
||||
shape: "path",
|
||||
path: "M171 128C181 130 188 136 191 147L209 216C210 224 206 230 199 232C191 234 186 229 184 222L161 157C156 143 159 132 171 128Z"
|
||||
},
|
||||
{
|
||||
key: "left-forearm",
|
||||
label: "右前臂",
|
||||
shape: "path",
|
||||
path: "M31 218C38 217 49 221 56 225L44 298C43 307 38 312 30 311C22 310 18 304 20 295Z"
|
||||
},
|
||||
{
|
||||
key: "right-forearm",
|
||||
label: "左前臂",
|
||||
shape: "path",
|
||||
path: "M209 218C202 217 191 221 184 225L196 298C197 307 202 312 210 311C218 310 222 304 220 295Z"
|
||||
},
|
||||
{
|
||||
key: "left-hand",
|
||||
label: "右手",
|
||||
shape: "path",
|
||||
path: "M20 313C22 306 40 307 44 314L42 337C40 347 34 353 28 350C23 347 19 339 18 330Z"
|
||||
},
|
||||
{
|
||||
key: "right-hand",
|
||||
label: "左手",
|
||||
shape: "path",
|
||||
path: "M220 313C218 306 200 307 196 314L198 337C200 347 206 353 212 350C217 347 221 339 222 330Z"
|
||||
},
|
||||
{
|
||||
key: "left-chest",
|
||||
label: "右胸",
|
||||
shape: "path",
|
||||
path: "M94 116C103 112 111 110 119 110L119 199C107 199 96 197 86 193L83 151Z"
|
||||
},
|
||||
{
|
||||
key: "right-chest",
|
||||
label: "左胸",
|
||||
shape: "path",
|
||||
path: "M121 110C129 110 137 112 146 116L157 151L154 193C144 197 133 199 121 199Z"
|
||||
},
|
||||
{
|
||||
key: "left-abdomen",
|
||||
label: "右腹",
|
||||
shape: "path",
|
||||
path: "M86 197C96 201 107 203 119 203L119 263C108 263 98 261 89 256Z"
|
||||
},
|
||||
{
|
||||
key: "right-abdomen",
|
||||
label: "左腹",
|
||||
shape: "path",
|
||||
path: "M121 203C133 203 144 201 154 197L151 256C142 261 132 263 121 263Z"
|
||||
},
|
||||
{
|
||||
key: "left-waist",
|
||||
label: "右腰",
|
||||
shape: "path",
|
||||
path: "M89 259C98 264 108 266 119 267L119 302C107 302 96 299 86 294Z"
|
||||
},
|
||||
{
|
||||
key: "right-waist",
|
||||
label: "左腰",
|
||||
shape: "path",
|
||||
path: "M121 267C132 266 142 264 151 259L154 294C144 299 133 302 121 302Z"
|
||||
},
|
||||
{
|
||||
key: "left-thigh",
|
||||
label: "右大腿",
|
||||
shape: "path",
|
||||
path: "M86 298C96 302 106 304 117 302L113 368C104 372 91 372 82 368L79 333Z"
|
||||
},
|
||||
{
|
||||
key: "right-thigh",
|
||||
label: "左大腿",
|
||||
shape: "path",
|
||||
path: "M123 302C134 304 144 302 154 298L161 333L158 368C149 372 136 372 127 368Z"
|
||||
},
|
||||
{ key: "left-knee", label: "右膝", shape: "ellipse", cx: 97, cy: 382, rx: 16, ry: 15 },
|
||||
{ key: "right-knee", label: "左膝", shape: "ellipse", cx: 143, cy: 382, rx: 16, ry: 15 },
|
||||
{
|
||||
key: "left-calf",
|
||||
label: "右小腿",
|
||||
shape: "path",
|
||||
path: "M83 397C92 400 102 400 111 397L108 462C100 466 91 466 83 462L80 424Z"
|
||||
},
|
||||
{
|
||||
key: "right-calf",
|
||||
label: "左小腿",
|
||||
shape: "path",
|
||||
path: "M129 397C138 400 148 400 157 397L160 424L157 462C149 466 140 466 132 462Z"
|
||||
},
|
||||
{
|
||||
key: "left-foot",
|
||||
label: "右脚",
|
||||
shape: "path",
|
||||
path: "M83 462C90 465 101 465 108 462L110 480C110 489 103 494 91 494H77C72 493 73 487 78 482Z"
|
||||
},
|
||||
{
|
||||
key: "right-foot",
|
||||
label: "左脚",
|
||||
shape: "path",
|
||||
path: "M132 462C139 465 150 465 157 462L162 482C167 487 168 493 163 494H149C137 494 130 489 130 480Z"
|
||||
}
|
||||
];
|
||||
|
||||
const selectedText = computed(() => {
|
||||
const selectedLabelList = selectedCodeList.value.map(item => areaLabelMap.value.get(item) || item);
|
||||
return selectedLabelList.length ? `已选部位:${selectedLabelList.join("、")}` : "已选部位:未选择";
|
||||
});
|
||||
|
||||
const areaLabelMap = computed(() => new Map(areas.map(item => [item.key, item.label])));
|
||||
const selectedCodeList = computed(() => props.modelValue?.selectedCodeList || []);
|
||||
|
||||
function isSelected(code: string): boolean {
|
||||
return selectedCodeList.value.includes(code);
|
||||
}
|
||||
|
||||
function toggle(code: string): void {
|
||||
if (props.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(selectedCodeList.value);
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
|
||||
emit("update:modelValue", {
|
||||
selectedCodeList: Array.from(next)
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.body-selector {
|
||||
display: flex;
|
||||
padding: .75rem;
|
||||
gap: .75rem;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: #f8fbff;
|
||||
border-radius: 1.25rem;
|
||||
|
||||
.figure {
|
||||
width: 100%;
|
||||
height: 31rem;
|
||||
max-width: 16rem;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.face-guide {
|
||||
fill: none;
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.area {
|
||||
fill: transparent;
|
||||
stroke: #64748b;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
pointer-events: all;
|
||||
stroke-width: 2;
|
||||
transition: fill .2s ease, stroke .2s ease, stroke-width .2s ease;
|
||||
|
||||
&:focus-visible {
|
||||
stroke: #0f766e;
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
&.active {
|
||||
fill: rgba(15, 118, 110, .24);
|
||||
stroke: #0f766e;
|
||||
stroke-width: 4;
|
||||
}
|
||||
}
|
||||
|
||||
&.readonly {
|
||||
.area {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
margin: 0;
|
||||
color: #52607a;
|
||||
font-size: .875rem;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.body-selector {
|
||||
background: rgba(15, 23, 42, .72);
|
||||
|
||||
.face-guide {
|
||||
stroke: #cbd5e1;
|
||||
}
|
||||
|
||||
.area {
|
||||
fill: transparent;
|
||||
stroke: #94a3b8;
|
||||
|
||||
&.active {
|
||||
fill: rgba(94, 234, 212, .24);
|
||||
stroke: #5eead4;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,529 @@
|
||||
<template>
|
||||
<div class="code-scanner">
|
||||
<div class="stage">
|
||||
<video ref="scannerVideoRef" class="video" autoplay muted playsinline />
|
||||
<canvas ref="scannerCanvasRef" class="scanner" />
|
||||
<div class="scan-frame">
|
||||
<span class="scan-line" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="tips" v-text="displayStatus" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {BrowserMultiFormatReader} from "@zxing/browser";
|
||||
import {BarcodeFormat, DecodeHintType, NotFoundException, type Result} from "@zxing/library";
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
activeText?: string;
|
||||
initFailedText?: string;
|
||||
interruptedText?: string;
|
||||
permissionDeniedText?: string;
|
||||
sessionKey?: number | string;
|
||||
statusText?: string;
|
||||
unsupportedText?: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeText: "正在识别,请将二维码移入取景框",
|
||||
initFailedText: "摄像头预览初始化失败",
|
||||
interruptedText: "识别中断,请检查摄像头权限或画面清晰度",
|
||||
permissionDeniedText: "无法启动摄像头,请确认已授予浏览器相机权限",
|
||||
sessionKey: 0,
|
||||
statusText: "",
|
||||
unsupportedText: "当前浏览器或 WebView 未开放摄像头能力"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
decode: [value: string];
|
||||
"status-change": [value: string];
|
||||
}>();
|
||||
|
||||
const scanHints = new Map<DecodeHintType, BarcodeFormat[]>();
|
||||
scanHints.set(DecodeHintType.POSSIBLE_FORMATS, [
|
||||
BarcodeFormat.QR_CODE,
|
||||
BarcodeFormat.CODE_128,
|
||||
BarcodeFormat.CODE_39,
|
||||
BarcodeFormat.EAN_13,
|
||||
BarcodeFormat.EAN_8
|
||||
]);
|
||||
|
||||
const scannerReader = new BrowserMultiFormatReader(scanHints);
|
||||
const scannerVideoRef = ref<HTMLVideoElement | null>(null);
|
||||
const scannerCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const scannerControls = shallowRef<{ stop: () => void } | null>(null);
|
||||
const localStatus = ref("");
|
||||
const overlayPoints = ref<Point[]>([]);
|
||||
let latestDecodedText = "";
|
||||
let clearOverlayTimer: number | undefined;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const displayStatus = computed(() => props.statusText || localStatus.value);
|
||||
|
||||
function updateStatus(value: string): void {
|
||||
localStatus.value = value;
|
||||
emit("status-change", value);
|
||||
}
|
||||
|
||||
function clearOverlay(delay = 0): void {
|
||||
if (clearOverlayTimer) {
|
||||
window.clearTimeout(clearOverlayTimer);
|
||||
clearOverlayTimer = undefined;
|
||||
}
|
||||
|
||||
if (delay <= 0) {
|
||||
overlayPoints.value = [];
|
||||
drawOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
clearOverlayTimer = window.setTimeout(() => {
|
||||
overlayPoints.value = [];
|
||||
drawOverlay();
|
||||
clearOverlayTimer = undefined;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function syncCanvasSize(): void {
|
||||
if (!scannerVideoRef.value || !scannerCanvasRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = scannerVideoRef.value.clientWidth;
|
||||
const height = scannerVideoRef.value.clientHeight;
|
||||
if (!width || !height) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scannerCanvasRef.value.width !== width || scannerCanvasRef.value.height !== height) {
|
||||
scannerCanvasRef.value.width = width;
|
||||
scannerCanvasRef.value.height = height;
|
||||
}
|
||||
drawOverlay();
|
||||
}
|
||||
|
||||
function getDisplayPoint(point: Point): Point {
|
||||
if (!scannerVideoRef.value) {
|
||||
return point;
|
||||
}
|
||||
|
||||
const sourceWidth = scannerVideoRef.value.videoWidth || scannerVideoRef.value.clientWidth;
|
||||
const sourceHeight = scannerVideoRef.value.videoHeight || scannerVideoRef.value.clientHeight;
|
||||
const targetWidth = scannerVideoRef.value.clientWidth;
|
||||
const targetHeight = scannerVideoRef.value.clientHeight;
|
||||
const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
|
||||
const offsetX = (targetWidth - sourceWidth * scale) / 2;
|
||||
const offsetY = (targetHeight - sourceHeight * scale) / 2;
|
||||
|
||||
return {
|
||||
x: point.x * scale + offsetX,
|
||||
y: point.y * scale + offsetY
|
||||
};
|
||||
}
|
||||
|
||||
function sortPointsClockwise(points: Point[]): Point[] {
|
||||
const center = points.reduce((result, item) => ({
|
||||
x: result.x + item.x / points.length,
|
||||
y: result.y + item.y / points.length
|
||||
}), { x: 0, y: 0 });
|
||||
|
||||
return [...points].sort((left, right) => {
|
||||
const leftAngle = Math.atan2(left.y - center.y, left.x - center.x);
|
||||
const rightAngle = Math.atan2(right.y - center.y, right.x - center.x);
|
||||
return leftAngle - rightAngle;
|
||||
});
|
||||
}
|
||||
|
||||
function buildQuadrilateralFromThreePoints(points: Point[]): Point[] {
|
||||
let cornerIndex = 0;
|
||||
let minDotValue = Number.POSITIVE_INFINITY;
|
||||
|
||||
points.forEach((point, index) => {
|
||||
const prevPoint = points[(index + 1) % points.length];
|
||||
const nextPoint = points[(index + 2) % points.length];
|
||||
const prevVectorX = prevPoint.x - point.x;
|
||||
const prevVectorY = prevPoint.y - point.y;
|
||||
const nextVectorX = nextPoint.x - point.x;
|
||||
const nextVectorY = nextPoint.y - point.y;
|
||||
const prevDistance = Math.hypot(prevVectorX, prevVectorY);
|
||||
const nextDistance = Math.hypot(nextVectorX, nextVectorY);
|
||||
if (!prevDistance || !nextDistance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dotValue = Math.abs((prevVectorX * nextVectorX + prevVectorY * nextVectorY) / (prevDistance * nextDistance));
|
||||
if (dotValue < minDotValue) {
|
||||
minDotValue = dotValue;
|
||||
cornerIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
const cornerPoint = points[cornerIndex];
|
||||
const firstEdgePoint = points[(cornerIndex + 1) % points.length];
|
||||
const secondEdgePoint = points[(cornerIndex + 2) % points.length];
|
||||
const fourthPoint = {
|
||||
x: firstEdgePoint.x + secondEdgePoint.x - cornerPoint.x,
|
||||
y: firstEdgePoint.y + secondEdgePoint.y - cornerPoint.y
|
||||
};
|
||||
|
||||
return sortPointsClockwise([
|
||||
cornerPoint,
|
||||
firstEdgePoint,
|
||||
fourthPoint,
|
||||
secondEdgePoint
|
||||
]);
|
||||
}
|
||||
|
||||
function buildOutlinePoints(points: Point[]): Point[] {
|
||||
if (4 <= points.length) {
|
||||
return sortPointsClockwise(points).slice(0, 4);
|
||||
}
|
||||
|
||||
if (3 === points.length) {
|
||||
return buildQuadrilateralFromThreePoints(points);
|
||||
}
|
||||
|
||||
if (!points.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const xValues = points.map(item => item.x);
|
||||
const yValues = points.map(item => item.y);
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValues);
|
||||
|
||||
return [
|
||||
{ x: minX, y: minY },
|
||||
{ x: maxX, y: minY },
|
||||
{ x: maxX, y: maxY },
|
||||
{ x: minX, y: maxY }
|
||||
];
|
||||
}
|
||||
|
||||
function expandOutlinePoints(points: Point[]): Point[] {
|
||||
if (!points.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const center = points.reduce((result, item) => ({
|
||||
x: result.x + item.x / points.length,
|
||||
y: result.y + item.y / points.length
|
||||
}), { x: 0, y: 0 });
|
||||
const width = Math.max(...points.map(item => item.x)) - Math.min(...points.map(item => item.x));
|
||||
const height = Math.max(...points.map(item => item.y)) - Math.min(...points.map(item => item.y));
|
||||
const padding = Math.max(Math.min(width, height) * .18, 18);
|
||||
|
||||
return points.map((item) => {
|
||||
const offsetX = item.x - center.x;
|
||||
const offsetY = item.y - center.y;
|
||||
const distance = Math.hypot(offsetX, offsetY);
|
||||
if (!distance) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const scale = (distance + padding) / distance;
|
||||
return {
|
||||
x: center.x + offsetX * scale,
|
||||
y: center.y + offsetY * scale
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function drawOverlay(): void {
|
||||
if (!scannerCanvasRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = scannerCanvasRef.value.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, scannerCanvasRef.value.width, scannerCanvasRef.value.height);
|
||||
if (!overlayPoints.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayPoints = expandOutlinePoints(buildOutlinePoints(overlayPoints.value)).map(item => getDisplayPoint(item));
|
||||
if (displayPoints.length < 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
const edgeLengths = displayPoints.map((item, index) => {
|
||||
const nextPoint = displayPoints[(index + 1) % displayPoints.length];
|
||||
return Math.hypot(nextPoint.x - item.x, nextPoint.y - item.y);
|
||||
});
|
||||
const width = Math.max(edgeLengths[0] || 0, edgeLengths[2] || 0);
|
||||
const height = Math.max(edgeLengths[1] || 0, edgeLengths[3] || 0);
|
||||
const cornerLength = Math.max(14, Math.min(Math.min(width, height) * .2, 28));
|
||||
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = "#22c55e";
|
||||
|
||||
const drawCorner = (point: Point, prevPoint: Point, nextPoint: Point): void => {
|
||||
const prevOffsetX = prevPoint.x - point.x;
|
||||
const prevOffsetY = prevPoint.y - point.y;
|
||||
const nextOffsetX = nextPoint.x - point.x;
|
||||
const nextOffsetY = nextPoint.y - point.y;
|
||||
const prevDistance = Math.hypot(prevOffsetX, prevOffsetY);
|
||||
const nextDistance = Math.hypot(nextOffsetX, nextOffsetY);
|
||||
if (!prevDistance || !nextDistance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevScale = cornerLength / prevDistance;
|
||||
const nextScale = cornerLength / nextDistance;
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(point.x + prevOffsetX * prevScale, point.y + prevOffsetY * prevScale);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.lineTo(point.x + nextOffsetX * nextScale, point.y + nextOffsetY * nextScale);
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
displayPoints.forEach((point, index) => {
|
||||
const prevPoint = displayPoints[(index - 1 + displayPoints.length) % displayPoints.length];
|
||||
const nextPoint = displayPoints[(index + 1) % displayPoints.length];
|
||||
drawCorner(point, prevPoint, nextPoint);
|
||||
});
|
||||
}
|
||||
|
||||
function updateOverlay(result: Result): void {
|
||||
overlayPoints.value = result.getResultPoints()
|
||||
.map(item => ({
|
||||
x: item.getX(),
|
||||
y: item.getY()
|
||||
}))
|
||||
.filter(item => Number.isFinite(item.x) && Number.isFinite(item.y));
|
||||
drawOverlay();
|
||||
}
|
||||
|
||||
async function startScanner(): Promise<void> {
|
||||
if (!props.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
updateStatus(window.isSecureContext ? props.unsupportedText : "当前页面不是 HTTPS 安全环境,无法调用摄像头");
|
||||
return;
|
||||
}
|
||||
|
||||
stopScanner();
|
||||
latestDecodedText = "";
|
||||
updateStatus(props.activeText);
|
||||
|
||||
await nextTick();
|
||||
if (!scannerVideoRef.value) {
|
||||
updateStatus(props.initFailedText);
|
||||
return;
|
||||
}
|
||||
|
||||
syncCanvasSize();
|
||||
try {
|
||||
scannerControls.value = await scannerReader.decodeFromConstraints({
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: {
|
||||
ideal: "environment"
|
||||
}
|
||||
}
|
||||
}, scannerVideoRef.value, (result, error) => {
|
||||
if (result) {
|
||||
updateOverlay(result);
|
||||
clearOverlay(900);
|
||||
const decodedText = result.getText().trim();
|
||||
if (!decodedText || decodedText === latestDecodedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestDecodedText = decodedText;
|
||||
emit("decode", decodedText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
clearOverlay(120);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
updateStatus(props.interruptedText);
|
||||
clearOverlay();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
updateStatus(props.permissionDeniedText);
|
||||
}
|
||||
}
|
||||
|
||||
function stopScanner(): void {
|
||||
scannerControls.value?.stop();
|
||||
scannerControls.value = null;
|
||||
if (scannerVideoRef.value?.srcObject instanceof MediaStream) {
|
||||
for (const track of scannerVideoRef.value.srcObject.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
scannerVideoRef.value.srcObject = null;
|
||||
}
|
||||
clearOverlay();
|
||||
}
|
||||
|
||||
watch(() => [props.visible, props.sessionKey] as const, async ([visible]) => {
|
||||
if (!visible) {
|
||||
stopScanner();
|
||||
return;
|
||||
}
|
||||
|
||||
await startScanner();
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncCanvasSize();
|
||||
});
|
||||
if (scannerVideoRef.value) {
|
||||
resizeObserver.observe(scannerVideoRef.value);
|
||||
scannerVideoRef.value.addEventListener("loadedmetadata", syncCanvasSize);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scannerVideoRef.value) {
|
||||
scannerVideoRef.value.removeEventListener("loadedmetadata", syncCanvasSize);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
if (clearOverlayTimer) {
|
||||
window.clearTimeout(clearOverlayTimer);
|
||||
}
|
||||
stopScanner();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.code-scanner {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
max-width: 30rem;
|
||||
|
||||
.stage {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 1rem;
|
||||
background: #0f172a;
|
||||
|
||||
.video,
|
||||
.scanner,
|
||||
.scan-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.video {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.scanner {
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scan-frame {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
max-width: 18rem;
|
||||
aspect-ratio: 1;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
.scan-line {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
height: .1875rem;
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, rgba(34, 197, 94, 0), rgba(74, 222, 128, .95), rgba(34, 197, 94, 0));
|
||||
animation: scan-line-down 3s cubic-bezier(0, .5, 1, .5) infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
color: #52607a;
|
||||
margin: .625rem 0 0;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan-line-down {
|
||||
0% {
|
||||
top: 8%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 92%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.code-scanner {
|
||||
background: rgba(30, 41, 59, .9);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
|
||||
.tips {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.scan-frame {
|
||||
.corner {
|
||||
border-color: #4ade80;
|
||||
filter: drop-shadow(0 0 .5rem rgba(74, 222, 128, .45));
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
background: linear-gradient(90deg, rgba(74, 222, 128, 0), rgba(134, 239, 172, .95), rgba(74, 222, 128, 0));
|
||||
box-shadow: 0 0 .875rem rgba(74, 222, 128, .8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="customer-profile-card">
|
||||
<t-button v-if="photoURL" variant="text" class="avatar-trigger" @click="openPhotoViewer">
|
||||
<t-image :src="photoURL" :alt="customerName" fit="cover" class="avatar" />
|
||||
</t-button>
|
||||
<div v-else class="avatar fallback" v-text="avatarText" />
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<div class="name">
|
||||
<span v-text="customerName"></span>
|
||||
<gender-icon v-if="customer?.gender" :value="customer.gender" size="1rem" />
|
||||
</div>
|
||||
<span class="gray" v-text="customerCode" />
|
||||
</div>
|
||||
<span class="telephone gray" v-text="customerTelephone" />
|
||||
</div>
|
||||
<t-image-viewer
|
||||
v-model:visible="photoViewerVisible"
|
||||
:images="photoViewerImages"
|
||||
show-index
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerProfileCard"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
customer?: Customer;
|
||||
}>();
|
||||
|
||||
const photoViewerVisible = ref(false);
|
||||
const customer = computed(() => props.customer);
|
||||
const customerName = computed(() => customer.value?.name || customer.value?.code || "");
|
||||
const avatarText = computed(() => customerName.value.slice(0, 1));
|
||||
const customerCode = computed(() => customer.value?.code || "");
|
||||
const customerTelephone = computed(() => customer.value?.telephone || "");
|
||||
const photoURL = computed(() => customer.value ? getCustomerPhotoURL(customer.value) : "");
|
||||
const photoViewerImages = computed(() => photoURL.value ? [photoURL.value] : []);
|
||||
|
||||
function openPhotoViewer(): void {
|
||||
if (!photoURL.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
photoViewerVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-profile-card {
|
||||
gap: .875rem;
|
||||
padding: .5rem;
|
||||
display: flex;
|
||||
background: #e6eeff;
|
||||
border-radius: 1.125rem;
|
||||
|
||||
.avatar-trigger {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: block;
|
||||
background: transparent;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: block;
|
||||
border-radius: .875rem;
|
||||
|
||||
&.fallback {
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
font-size: 1.5rem;
|
||||
background: #dbeafe;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: .5rem;
|
||||
justify-content: space-between;
|
||||
|
||||
.name {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.telephone {
|
||||
font-size: .875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.customer-profile-card {
|
||||
background: rgba(30, 41, 59, .9);
|
||||
|
||||
.content p {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.line h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.gender {
|
||||
color: #93c5fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<t-popup v-model="popupVisible" placement="bottom">
|
||||
<div class="customer-selector bg-white">
|
||||
<h3 class="title">选择客户</h3>
|
||||
<div class="search-wrapper">
|
||||
<t-input
|
||||
v-model.trim="keyword"
|
||||
class="search"
|
||||
borderless
|
||||
clearable
|
||||
placeholder="姓名 / 编码 / 手机号"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="customerOptions.length" ref="resultListRef" class="result-list">
|
||||
<t-cell-group>
|
||||
<t-cell
|
||||
v-for="(item, index) in customerOptions"
|
||||
:key="item.id || item.code || index"
|
||||
:description="item.telephone || '未填写电话'"
|
||||
:note="item.code"
|
||||
hover
|
||||
@click="selectCustomer(item)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="cell-title">
|
||||
<span v-text="item.name || item.code || '未命名客户'" />
|
||||
<gender-icon :value="item.gender" />
|
||||
</div>
|
||||
</template>
|
||||
<template #leftIcon>
|
||||
<t-image v-if="getCustomerPhotoURL(item)" :src="getCustomerPhotoURL(item)" :alt="item.name" fit="cover" class="avatar" />
|
||||
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div class="load-state" v-text="loadStateText" />
|
||||
</div>
|
||||
<div v-else class="empty-block" v-text="loading ? '正在加载客户' : '未找到匹配客户'" />
|
||||
</div>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Scroller } from "timi-web";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
"update:visible": [value: boolean];
|
||||
change: [value: string];
|
||||
}>();
|
||||
|
||||
const pageSize = 20;
|
||||
const scrollListenerName = "CustomerSelector";
|
||||
const keyword = ref("");
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const pageIndex = ref(0);
|
||||
const totalCount = ref(0);
|
||||
const customerOptions = ref<Customer[]>([]);
|
||||
const resultListRef = ref<HTMLElement>();
|
||||
let searchToken = 0;
|
||||
let keywordTimer: number | undefined;
|
||||
|
||||
const loadStateText = computed(() => {
|
||||
if (loading.value) {
|
||||
return "正在加载更多";
|
||||
}
|
||||
|
||||
return noMore.value ? `共 ${totalCount.value} 位客户` : "上拉加载更多";
|
||||
});
|
||||
|
||||
const popupVisible = computed({
|
||||
get() {
|
||||
return props.visible;
|
||||
},
|
||||
set(value: boolean) {
|
||||
emit("update:visible", value);
|
||||
}
|
||||
});
|
||||
|
||||
async function reloadCustomers(): Promise<void> {
|
||||
pageIndex.value = 0;
|
||||
totalCount.value = 0;
|
||||
customerOptions.value = [];
|
||||
noMore.value = false;
|
||||
await loadCustomers();
|
||||
}
|
||||
|
||||
async function loadCustomers(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = searchToken + 1;
|
||||
searchToken = token;
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await CustomerAPI.searchPage({
|
||||
index: pageIndex.value,
|
||||
size: pageSize,
|
||||
equalsExample: {
|
||||
keyword: keyword.value || undefined
|
||||
}
|
||||
});
|
||||
if (token !== searchToken) {
|
||||
return;
|
||||
}
|
||||
const list = result.list || [];
|
||||
customerOptions.value = customerOptions.value.concat(list);
|
||||
totalCount.value = result.total || 0;
|
||||
pageIndex.value += 1;
|
||||
noMore.value = totalCount.value <= customerOptions.value.length || list.length < pageSize;
|
||||
} finally {
|
||||
if (token === searchToken) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bindScrollListener(): Promise<void> {
|
||||
if (!props.visible || customerOptions.value.length <= 0) {
|
||||
Scroller.removeListener(scrollListenerName);
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
Scroller.addBottomListener(scrollListenerName, loadCustomers, {
|
||||
distance: 48,
|
||||
canLoad: () => props.visible && !loading.value && !noMore.value,
|
||||
getElement: () => resultListRef.value
|
||||
});
|
||||
}
|
||||
|
||||
function selectCustomer(item: Customer): void {
|
||||
const id = item.id;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update:modelValue", id);
|
||||
emit("change", id);
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
watch(() => props.visible, async (value) => {
|
||||
if (!value) {
|
||||
keyword.value = "";
|
||||
window.clearTimeout(keywordTimer);
|
||||
Scroller.removeListener(scrollListenerName);
|
||||
return;
|
||||
}
|
||||
await reloadCustomers();
|
||||
await bindScrollListener();
|
||||
});
|
||||
|
||||
watch(keyword, async () => {
|
||||
if (!props.visible) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(keywordTimer);
|
||||
keywordTimer = window.setTimeout(async () => {
|
||||
await reloadCustomers();
|
||||
await bindScrollListener();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
watch(() => customerOptions.value.length, async () => {
|
||||
await bindScrollListener();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(keywordTimer);
|
||||
Scroller.removeListener(scrollListenerName);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-selector {
|
||||
display: flex;
|
||||
height: 70vh;
|
||||
padding-top: 1rem;
|
||||
border-radius: 1.25rem 1.25rem 0 0;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
z-index: 1;
|
||||
padding: 1rem 0 .5rem 0;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
|
||||
.search {
|
||||
--td-input-vertical-padding: 8px;
|
||||
|
||||
flex: 0;
|
||||
margin: 0 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.result-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
|
||||
.cell-title {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.load-state {
|
||||
padding: .75rem;
|
||||
color: #52607a;
|
||||
text-align: center;
|
||||
font-size: .875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: .5rem;
|
||||
|
||||
&.fallback {
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
font-size: 1.25rem;
|
||||
background: #dbeafe;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.avatar .t-image__img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #52607a;
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
background: #f8fbff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.customer-selector {
|
||||
background: rgba(15, 23, 42, .96);
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #e2e8f0;
|
||||
background: rgba(30, 41, 59, .9);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
|
||||
.load-state {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
|
||||
&.fallback {
|
||||
color: #93c5fd;
|
||||
background: rgba(30, 64, 175, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<t-icon
|
||||
v-if="iconName"
|
||||
:name="iconName"
|
||||
:size="size"
|
||||
:class="['gender-icon', `gender-icon--${tone}`]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
size?: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: "1rem",
|
||||
value: ""
|
||||
});
|
||||
|
||||
const normalizedValue = computed(() => (props.value || "").trim());
|
||||
const iconName = computed(() => {
|
||||
if (normalizedValue.value === "MALE" || normalizedValue.value === "男") {
|
||||
return "gender-male";
|
||||
}
|
||||
if (normalizedValue.value === "FEMALE" || normalizedValue.value === "女") {
|
||||
return "gender-female";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const tone = computed(() => normalizedValue.value === "FEMALE" || normalizedValue.value === "女" ? "female" : "male");
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.gender-icon {
|
||||
flex: none;
|
||||
|
||||
&--male {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&--female {
|
||||
color: #db2777;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.gender-icon {
|
||||
&--male {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
&--female {
|
||||
color: #f9a8d4;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="page-transition">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition :name="transitionName">
|
||||
<div :key="route.fullPath" class="pages">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import { useGlobalUIStore } from "@/store/globalUI";
|
||||
|
||||
defineOptions({
|
||||
name: "PageTransition"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const globalUIStore = useGlobalUIStore();
|
||||
|
||||
const transitionName = ref("push-left");
|
||||
const pageBackground = computed(() => globalUIStore.bodyBackground);
|
||||
|
||||
// ---------- 路由深度计算 ----------
|
||||
|
||||
const pathCache = new Map<string, number>();
|
||||
|
||||
function calcDepth(sourceRoute: RouteLocationNormalized): number {
|
||||
if (!sourceRoute.meta.dynamicDepth) {
|
||||
return Number(sourceRoute.meta.depth ?? 0);
|
||||
}
|
||||
|
||||
const fullPath = sourceRoute.fullPath;
|
||||
const cachedDepth = pathCache.get(fullPath);
|
||||
if (cachedDepth !== undefined) {
|
||||
return cachedDepth;
|
||||
}
|
||||
|
||||
const baseDepth = Number(sourceRoute.meta.baseDepth ?? 0);
|
||||
const pathSegments = fullPath.split("?")[0].split("/").filter((pathSegment) => pathSegment !== "");
|
||||
const depth = baseDepth + (pathSegments.length - 1);
|
||||
pathCache.set(fullPath, depth);
|
||||
return depth;
|
||||
}
|
||||
|
||||
const unregisterGuard = router.beforeEach((to, from) => {
|
||||
const toDepth = calcDepth(to);
|
||||
const fromDepth = calcDepth(from);
|
||||
|
||||
if (fromDepth < toDepth) {
|
||||
transitionName.value = "push-left";
|
||||
} else if (toDepth < fromDepth) {
|
||||
transitionName.value = "push-right";
|
||||
} else {
|
||||
transitionName.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unregisterGuard();
|
||||
pathCache.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@easing: cubic-bezier(.19, 1, .22, 1);
|
||||
@duration: 520ms;
|
||||
@brightness: .98;
|
||||
@shadow-intensity: .15;
|
||||
@page-offset: 25%;
|
||||
|
||||
.page-transition {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
|
||||
// 此 wrapper 必须独立,不可将 class=pages 直接加在 <component> 上:
|
||||
// 否则页面 root element 与动画容器同为一个 DOM 节点,
|
||||
// 页面自身的 padding 会使 position:fixed; width:100% 的盒子溢出视口。
|
||||
.pages {
|
||||
height: 100%;
|
||||
background: v-bind(pageBackground);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
// ---------- push-left / push-right 公共激活状态 ----------
|
||||
|
||||
.push-left-enter-active,
|
||||
.push-left-leave-active,
|
||||
.push-right-enter-active,
|
||||
.push-right-leave-active {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
transition: transform @duration @easing, opacity .2s linear;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
// ---------- 前进:下一页从右滑入,当前页缩退到左侧 ----------
|
||||
|
||||
.push-left-enter-from {
|
||||
transform: translate3d(100%, 0, 0);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||
}
|
||||
|
||||
.push-left-enter-to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.push-left-leave-active {
|
||||
transition: all @duration @easing, filter .3s;
|
||||
}
|
||||
|
||||
.push-left-leave-to {
|
||||
filter: brightness(@brightness);
|
||||
transform: translate3d(-@page-offset, 0, 0);
|
||||
}
|
||||
|
||||
// ---------- 返回:当前页从右滑出,上一页从左侧复位 ----------
|
||||
|
||||
.push-right-enter-active {
|
||||
z-index: 0;
|
||||
transition: transform @duration @easing, opacity .3s;
|
||||
}
|
||||
|
||||
.push-right-enter-from {
|
||||
transform: translate3d(-@page-offset, 0, 0);
|
||||
}
|
||||
|
||||
.push-right-enter-to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.push-right-leave-active {
|
||||
z-index: 1;
|
||||
transition: transform @duration @easing, box-shadow .3s;
|
||||
}
|
||||
|
||||
.push-right-leave-from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.push-right-leave-to {
|
||||
transform: translate3d(105%, 0, 0);
|
||||
box-shadow: 4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="photo-field" :class="{ compact: size === 'compact' }">
|
||||
<t-button
|
||||
v-if="!modelValue"
|
||||
theme="primary"
|
||||
variant="dashed"
|
||||
class="add"
|
||||
@click="$emit('add')"
|
||||
>
|
||||
<span v-text="addText" />
|
||||
</t-button>
|
||||
<div v-else class="card">
|
||||
<t-button variant="text" class="preview-trigger" @click="handlePhotoClick">
|
||||
<t-image
|
||||
class="image"
|
||||
:src="modelValue"
|
||||
:alt="altText"
|
||||
fit="cover"
|
||||
/>
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="showRemove"
|
||||
class="remove"
|
||||
theme="danger"
|
||||
shape="circle"
|
||||
size="extra-small"
|
||||
@click="clearPhoto"
|
||||
>
|
||||
<t-icon name="close" size="1rem" />
|
||||
</t-button>
|
||||
</div>
|
||||
<t-image-viewer
|
||||
v-model:visible="photoViewerVisible"
|
||||
:images="photoViewerImages"
|
||||
show-index
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
addText?: string;
|
||||
altText?: string;
|
||||
modelValue: string;
|
||||
preview?: boolean;
|
||||
showRemove?: boolean;
|
||||
size?: "normal" | "compact";
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
addText: "拍照",
|
||||
altText: "照片",
|
||||
preview: true,
|
||||
showRemove: true,
|
||||
size: "normal"
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
name: "PhotoField"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [];
|
||||
clear: [];
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const photoViewerVisible = ref(false);
|
||||
const photoViewerImages = computed(() => props.modelValue ? [props.modelValue] : []);
|
||||
|
||||
function handlePhotoClick(): void {
|
||||
if (!props.preview) {
|
||||
emit("add");
|
||||
return;
|
||||
}
|
||||
|
||||
photoViewerVisible.value = true;
|
||||
}
|
||||
|
||||
function clearPhoto(): void {
|
||||
emit("update:modelValue", "");
|
||||
emit("clear");
|
||||
photoViewerVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.photo-field {
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
|
||||
.add {
|
||||
--td-button-border-radius: .875rem;
|
||||
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
|
||||
.preview-trigger {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: transparent;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
|
||||
.remove {
|
||||
top: .375rem;
|
||||
right: .375rem;
|
||||
position: absolute;
|
||||
|
||||
:deep(.t-button__content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
.add,
|
||||
.card {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="photo-section">
|
||||
<div class="t-form__label" v-text="title" />
|
||||
<div class="photo-shell" :class="{ 'without-add': !showAdd }">
|
||||
<div class="photo-row">
|
||||
<div v-if="!displayPhotoList.length" class="photo-empty" v-text="emptyText" />
|
||||
<div
|
||||
v-for="(item, index) in displayPhotoList"
|
||||
:key="`${item}-${index}`"
|
||||
class="photo-item"
|
||||
>
|
||||
<t-button variant="text" class="photo-preview-trigger" @click="openPhotoViewer(index)">
|
||||
<t-image :src="item" alt="登记照片" fit="cover" class="photo-image" />
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="showRemove && modelValue.length"
|
||||
theme="danger"
|
||||
class="remove"
|
||||
shape="circle"
|
||||
size="extra-small"
|
||||
@click="removePhoto(index)"
|
||||
>
|
||||
<t-icon name="close" size="1rem" />
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<t-button
|
||||
v-if="showAdd"
|
||||
theme="primary"
|
||||
variant="dashed"
|
||||
class="add"
|
||||
@click="$emit('add')"
|
||||
>
|
||||
<span v-text="addText" />
|
||||
</t-button>
|
||||
</div>
|
||||
<t-image-viewer
|
||||
v-model:visible="photoViewerVisible"
|
||||
:images="photoViewerImages"
|
||||
show-index
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
addText?: string;
|
||||
emptyText?: string;
|
||||
fallbackImage?: string;
|
||||
modelValue: string[];
|
||||
showAdd?: boolean;
|
||||
showRemove?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: "照片列表",
|
||||
addText: "拍照",
|
||||
emptyText: "暂无照片",
|
||||
showAdd: true,
|
||||
showRemove: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [];
|
||||
"update:modelValue": [value: string[]];
|
||||
}>();
|
||||
|
||||
const photoViewerVisible = ref(false);
|
||||
const photoViewerImages = ref<string[]>([]);
|
||||
const displayPhotoList = computed(() => {
|
||||
if (props.modelValue.length) {
|
||||
return props.modelValue;
|
||||
}
|
||||
|
||||
return props.fallbackImage ? [props.fallbackImage] : [];
|
||||
});
|
||||
|
||||
function removePhoto(index: number): void {
|
||||
if (index < 0 || props.modelValue.length <= index) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = props.modelValue.filter((_, itemIndex) => itemIndex !== index);
|
||||
emit("update:modelValue", nextValue);
|
||||
}
|
||||
|
||||
function openPhotoViewer(index: number): void {
|
||||
photoViewerImages.value = [...displayPhotoList.value.slice(index), ...displayPhotoList.value.slice(0, index)];
|
||||
photoViewerVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.photo-section {
|
||||
display: grid;
|
||||
gap: .375rem;
|
||||
|
||||
.photo-shell {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
grid-template-columns: minmax(0, 1fr) 5.5rem;
|
||||
align-items: start;
|
||||
|
||||
&.without-add {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.photo-row {
|
||||
gap: .75rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.add {
|
||||
--td-button-border-radius: .875rem;
|
||||
|
||||
flex: 0 0 5.5rem;
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
flex: 0 0 5.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.photo-preview-trigger {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.photo-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.remove {
|
||||
top: .375rem;
|
||||
right: .375rem;
|
||||
position: absolute;
|
||||
|
||||
:deep(.t-button__content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-empty {
|
||||
width: 100%;
|
||||
color: #52607a;
|
||||
display: flex;
|
||||
padding: 1rem 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fbff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.photo-section {
|
||||
.field-title,
|
||||
.photo-empty {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.photo-empty {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
|
||||
.add {
|
||||
background: rgba(37, 99, 235, .16);
|
||||
border-color: rgba(96, 165, 250, .28);
|
||||
|
||||
span {
|
||||
color: #93c5fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<section class="register-record-calendar">
|
||||
<div class="calendar-head">
|
||||
<t-button class="month-btn" shape="square" variant="text" @click="shiftMonth(-1)">
|
||||
<template #icon>
|
||||
<t-icon name="chevron-left" />
|
||||
</template>
|
||||
</t-button>
|
||||
<h2 v-text="monthTitle" />
|
||||
<div class="head-actions">
|
||||
<t-button class="month-btn" shape="square" variant="text" @click="shiftMonth(1)">
|
||||
<template #icon>
|
||||
<t-icon name="chevron-right" />
|
||||
</template>
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="showRegistrationEventFilter"
|
||||
class="filter-btn"
|
||||
theme="primary"
|
||||
size="extra-small"
|
||||
variant="outline"
|
||||
@click="registrationEventFilterVisible = true"
|
||||
content="登记事件"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="filter" />
|
||||
</template>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="week-line">
|
||||
<span v-for="item in weekNameList" :key="item" v-text="item" />
|
||||
</div>
|
||||
<div class="calendar-grid">
|
||||
<button
|
||||
v-for="item in calendarDayList"
|
||||
:key="item.key"
|
||||
class="day-cell"
|
||||
:class="{muted: !item.inMonth, today: item.isToday, marked: 0 < item.records.length}"
|
||||
type="button"
|
||||
:disabled="!item.records.length"
|
||||
@click="openDayRecords(item)"
|
||||
>
|
||||
<span class="date" v-text="item.day" />
|
||||
<span v-if="item.records.length" class="count" v-text="`${item.records.length} 条`" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loading" class="empty-block">正在加载登记日历</div>
|
||||
<div v-else-if="!currentMonthFilteredRecords.length" class="empty-block" v-text="emptyText" />
|
||||
<t-popup v-model="dayRecordVisible" placement="bottom">
|
||||
<div class="day-record-panel">
|
||||
<h3 v-text="selectedDayTitle" />
|
||||
<t-cell-group v-if="selectedDayRecords.length" theme="card">
|
||||
<t-cell
|
||||
v-for="item in selectedDayRecords"
|
||||
:key="item.id"
|
||||
:title="getRecordRegistrationEventName(item)"
|
||||
:description="getRecordCustomerName(item)"
|
||||
:note="`${formatRecordTime(item.registeredAt)}`"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty-block">该日暂无登记事件记录</div>
|
||||
</div>
|
||||
</t-popup>
|
||||
<t-popup v-model="registrationEventFilterVisible" placement="bottom">
|
||||
<div class="filter-popup">
|
||||
<div class="title">登记事件筛选</div>
|
||||
<t-checkbox-group v-model="selectedRegistrationEventIdListProxy" class="items">
|
||||
<t-checkbox
|
||||
class="item"
|
||||
v-for="item in registrationEventList"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
:label="item.name"
|
||||
block
|
||||
borderless
|
||||
/>
|
||||
</t-checkbox-group>
|
||||
</div>
|
||||
</t-popup>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {RegistrationEvent} from "@/types/RegistrationEvent";
|
||||
import type {RegistrationEventRecord} from "@/types/RegistrationEventRecord";
|
||||
import {Time} from "timi-web";
|
||||
|
||||
interface CalendarDay {
|
||||
key: string;
|
||||
day: string;
|
||||
dateText: string;
|
||||
inMonth: boolean;
|
||||
isToday: boolean;
|
||||
records: RegistrationEventRecord[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
registrationEventList: RegistrationEvent[];
|
||||
customerName?: string;
|
||||
emptyText?: string;
|
||||
loading?: boolean;
|
||||
modelValue: Date;
|
||||
records: RegistrationEventRecord[];
|
||||
selectedRegistrationEventIdList: string[];
|
||||
showRegistrationEventFilter?: boolean;
|
||||
}>(), {
|
||||
customerName: "",
|
||||
emptyText: "当前月份暂无匹配登记",
|
||||
loading: false,
|
||||
showRegistrationEventFilter: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: Date];
|
||||
"update:selectedRegistrationEventIdList": [value: string[]];
|
||||
}>();
|
||||
|
||||
const weekNameList = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
const dayRecordVisible = ref(false);
|
||||
const registrationEventFilterVisible = ref(false);
|
||||
const selectedDayTitle = ref("");
|
||||
const selectedDayRecords = ref<RegistrationEventRecord[]>([]);
|
||||
|
||||
const selectedRegistrationEventIdListProxy = computed({
|
||||
get: () => props.selectedRegistrationEventIdList,
|
||||
set: value => emit("update:selectedRegistrationEventIdList", value)
|
||||
});
|
||||
const registrationEventNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of props.registrationEventList) {
|
||||
if (item.id) {
|
||||
map.set(item.id, item.name || "未命名事件");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const selectedRegistrationEventIdSet = computed(() => new Set(props.selectedRegistrationEventIdList));
|
||||
const filteredRecords = computed(() => {
|
||||
if (!props.showRegistrationEventFilter || !props.selectedRegistrationEventIdList.length) {
|
||||
return props.records;
|
||||
}
|
||||
|
||||
return props.records.filter(item => item.registrationEventId && selectedRegistrationEventIdSet.value.has(item.registrationEventId));
|
||||
});
|
||||
const currentMonthFilteredRecords = computed(() => filteredRecords.value.filter(item => isInCurrentMonth(item.registeredAt)));
|
||||
const monthTitle = computed(() => `${props.modelValue.getFullYear()} 年 ${props.modelValue.getMonth() + 1} 月`);
|
||||
const calendarDayList = computed<CalendarDay[]>(() => {
|
||||
const beginDate = getCalendarBeginDate(props.modelValue);
|
||||
const todayText = Time.formatDate(new Date());
|
||||
const list: CalendarDay[] = [];
|
||||
for (let index = 0; index < 42; index += 1) {
|
||||
const date = addDays(beginDate, index);
|
||||
const dateText = Time.formatDate(date);
|
||||
list.push({
|
||||
key: dateText,
|
||||
day: String(date.getDate()),
|
||||
dateText,
|
||||
inMonth: date.getMonth() === props.modelValue.getMonth(),
|
||||
isToday: dateText === todayText,
|
||||
records: filteredRecords.value.filter(item => getRecordDateText(item) === dateText)
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function shiftMonth(offset: number): void {
|
||||
emit("update:modelValue", addMonths(props.modelValue, offset));
|
||||
}
|
||||
|
||||
function openDayRecords(day: CalendarDay): void {
|
||||
if (!day.records.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDayTitle.value = `${day.dateText} 登记事件记录`;
|
||||
selectedDayRecords.value = [...day.records].sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0));
|
||||
dayRecordVisible.value = true;
|
||||
}
|
||||
|
||||
function getRecordRegistrationEventName(record?: RegistrationEventRecord): string {
|
||||
if (!record) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return record.registrationEvent?.name || (record.registrationEventId ? registrationEventNameMap.value.get(record.registrationEventId) : "") || "未知事件";
|
||||
}
|
||||
|
||||
function getRecordCustomerName(record: RegistrationEventRecord): string {
|
||||
return record.customer?.name || props.customerName || record.customerId || "未知客户";
|
||||
}
|
||||
|
||||
function formatRecordTime(value?: number): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const date = Time.toDateObject(value);
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
return Time.formatDateTime(date);
|
||||
}
|
||||
|
||||
function getRecordDateText(record: RegistrationEventRecord): string {
|
||||
const date = Time.toDateObject(record.registeredAt);
|
||||
return date ? Time.formatDate(date) : "";
|
||||
}
|
||||
|
||||
function isInCurrentMonth(value?: number): boolean {
|
||||
const date = Time.toDateObject(value);
|
||||
return !!date && date.getFullYear() === props.modelValue.getFullYear() && date.getMonth() === props.modelValue.getMonth();
|
||||
}
|
||||
|
||||
function getMonthStart(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function getCalendarBeginDate(date: Date): Date {
|
||||
const monthStart = getMonthStart(date);
|
||||
return addDays(monthStart, -monthStart.getDay());
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const value = new Date(date);
|
||||
value.setDate(value.getDate() + days);
|
||||
return value;
|
||||
}
|
||||
|
||||
function addMonths(date: Date, months: number): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth() + months, 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.register-record-calendar {
|
||||
gap: .875rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: .75rem;
|
||||
flex-direction: column;
|
||||
|
||||
.calendar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.month-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.week-line,
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.week-line {
|
||||
color: #52607a;
|
||||
font-size: .75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
gap: .375rem;
|
||||
|
||||
.day-cell {
|
||||
padding: .25rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
border: 1px solid var(--td-component-border);
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
align-items: flex-start;
|
||||
border-radius: .25rem;
|
||||
flex-direction: column;
|
||||
|
||||
.date {
|
||||
font-size: .875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.count {
|
||||
width: 100%;
|
||||
color: #2563eb;
|
||||
overflow: hidden;
|
||||
font-size: .625rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.muted {
|
||||
color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&.today {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
&.marked {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
padding: 1rem;
|
||||
color: #52607a;
|
||||
text-align: center;
|
||||
background: #f7faff;
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.day-record-panel,
|
||||
.filter-popup {
|
||||
gap: .875rem;
|
||||
padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom, 0)) 0;
|
||||
display: flex;
|
||||
background: #f5f5f8;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
flex-direction: column;
|
||||
|
||||
h3,
|
||||
.title {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-popup {
|
||||
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0)) 1rem;
|
||||
|
||||
.items {
|
||||
gap: .5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
.item {
|
||||
border-radius: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.register-record-calendar {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
|
||||
.calendar-head {
|
||||
h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.week-line {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
.day-cell {
|
||||
color: #e2e8f0;
|
||||
background: rgba(30, 41, 59, .9);
|
||||
|
||||
.count {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
&.muted {
|
||||
color: #94a3b8;
|
||||
background: rgba(15, 23, 42, .7);
|
||||
}
|
||||
|
||||
&.marked {
|
||||
background: rgba(37, 99, 235, .2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #94a3b8;
|
||||
background: rgba(30, 41, 59, .9);
|
||||
}
|
||||
}
|
||||
|
||||
.day-record-panel,
|
||||
.registration-event-filter-panel {
|
||||
background: #10161f;
|
||||
|
||||
h3,
|
||||
.title {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="registration-event-selector">
|
||||
<p v-if="label" class="t-form__label" :class="{required: requiredMark}" v-text="label" />
|
||||
<t-checkbox-group v-model="modelValue" class="group">
|
||||
<t-checkbox
|
||||
v-for="item in registrationEventList"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
class="item"
|
||||
borderless
|
||||
>
|
||||
<span v-text="item.name" />
|
||||
</t-checkbox>
|
||||
</t-checkbox-group>
|
||||
<div v-if="!registrationEventList.length" class="empty-block">暂无可用登记事件</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||
|
||||
interface Props {
|
||||
registrationEventList: RegistrationEvent[];
|
||||
label?: string;
|
||||
requiredMark?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
label: "登记事件",
|
||||
requiredMark: false
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string[]>({ required: true });
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.registration-event-selector {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: .5rem;
|
||||
|
||||
.t-form__label {
|
||||
margin: 0;
|
||||
|
||||
&.required::before {
|
||||
color: #e34d59;
|
||||
content: "*";
|
||||
margin-right: .25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
width: 100%;
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
:deep(.item) {
|
||||
margin: 0;
|
||||
padding: .875rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: #f8fbff;
|
||||
border: 1px solid rgba(148, 163, 184, .28);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #52607a;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.registration-event-selector {
|
||||
.label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-block,
|
||||
:deep(.item) {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div ref="sliderRef" class="segmented-slider" :style="sliderStyle">
|
||||
<span class="thumb" />
|
||||
<button
|
||||
v-for="(item, index) in options"
|
||||
:key="item.value"
|
||||
:ref="element => setOptionRef(element, index)"
|
||||
class="option"
|
||||
:class="{ active: item.value === modelValue }"
|
||||
type="button"
|
||||
@click="selectOption(item.value)"
|
||||
>
|
||||
<span v-text="item.label" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
|
||||
interface SegmentedSliderOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
options: SegmentedSliderOption[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
change: [value: string];
|
||||
}>();
|
||||
|
||||
const sliderRef = ref<HTMLElement>();
|
||||
const optionRefs = ref<HTMLElement[]>([]);
|
||||
const activeMetrics = reactive({
|
||||
left: 0,
|
||||
width: 0
|
||||
});
|
||||
let sliderResizeObserver: ResizeObserver | undefined;
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
const index = props.options.findIndex(item => item.value === props.modelValue);
|
||||
return 0 < index ? index : 0;
|
||||
});
|
||||
const sliderStyle = computed(() => ({
|
||||
"--thumb-left": `${activeMetrics.left}px`,
|
||||
"--thumb-width": `${activeMetrics.width}px`
|
||||
}));
|
||||
|
||||
function setOptionRef(element: Element | ComponentPublicInstance | null, index: number): void {
|
||||
if (element instanceof HTMLElement) {
|
||||
optionRefs.value[index] = element;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateThumbMetrics(): Promise<void> {
|
||||
await nextTick();
|
||||
const activeElement = optionRefs.value[activeIndex.value];
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeMetrics.left = activeElement.offsetLeft;
|
||||
activeMetrics.width = activeElement.offsetWidth;
|
||||
}
|
||||
|
||||
function selectOption(value: string): void {
|
||||
emit("update:modelValue", value);
|
||||
emit("change", value);
|
||||
}
|
||||
|
||||
watch(() => [
|
||||
props.modelValue,
|
||||
...props.options.map(item => `${item.value}:${item.label}`)
|
||||
], async () => {
|
||||
await updateThumbMetrics();
|
||||
});
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
optionRefs.value = [];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await updateThumbMetrics();
|
||||
if (!sliderRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
sliderResizeObserver = new ResizeObserver(() => {
|
||||
void updateThumbMetrics();
|
||||
});
|
||||
sliderResizeObserver.observe(sliderRef.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sliderResizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.segmented-slider {
|
||||
gap: 0;
|
||||
padding: .25rem;
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
background: #e5e7eb;
|
||||
border-radius: .875rem;
|
||||
|
||||
.thumb {
|
||||
top: .25rem;
|
||||
left: 0;
|
||||
bottom: .25rem;
|
||||
width: var(--thumb-width);
|
||||
z-index: 0;
|
||||
position: absolute;
|
||||
transform: translateX(var(--thumb-left));
|
||||
background: #fff;
|
||||
transition: width .22s ease, transform .22s ease;
|
||||
border-radius: .625rem;
|
||||
}
|
||||
|
||||
.option {
|
||||
flex: 0 0 auto;
|
||||
color: #52607a;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
padding: 0 .75rem;
|
||||
display: flex;
|
||||
outline: none;
|
||||
position: relative;
|
||||
font-size: .875rem;
|
||||
min-width: 2.75rem;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-appearance: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:active {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: inset 0 0 0 .0625rem rgba(15, 118, 110, .48);
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.segmented-slider {
|
||||
background: rgba(30, 41, 59, .9);
|
||||
|
||||
.thumb {
|
||||
background: rgba(15, 23, 42, .95);
|
||||
box-shadow: 0 .25rem .75rem rgba(2, 6, 23, .36);
|
||||
}
|
||||
|
||||
.option {
|
||||
color: #94a3b8;
|
||||
|
||||
&.active {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user