init project

This commit is contained in:
Timi
2026-08-01 18:01:23 +08:00
parent 286fa1ba56
commit 11c48bf00c
107 changed files with 19350 additions and 1 deletions
+313
View File
@@ -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>
+529
View File
@@ -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>
+130
View File
@@ -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>
+288
View File
@@ -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>
+58
View File
@@ -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>
+153
View File
@@ -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>
+144
View File
@@ -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>
+206
View File
@@ -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>
+186
View File
@@ -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>