mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
common/ had become the backend's library: 30 of its 41 modules were imported by nothing in the frontend, so the name no longer said what belonged there. It now holds only what the frontend or the build imports too; backend-only code moved to server/, flat except for datasets/ (the dataset engine and its readers). The *-db/ dataset directories stay in common/: self-hosting docs and Docker volumes mount those paths. frontend/utils/ keeps its app-wide modules at the root and gains ip/ (IP / ASN shaping and validation), report/ (the report pipeline) and features/ (the pure layer of a single feature). Paths only — no logic changes. The Dockerfile copies server/, and the AGENTS.md files say where a new module goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
5.5 KiB
Vue
128 lines
5.5 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<div class="sticky top-0 z-10 -mt-3 pt-3 bg-card pb-1.5 text-xs text-muted-foreground">
|
|
{{ t('globalping.Selected') }}
|
|
<span :class="max && modelValue.length > max ? 'text-destructive font-semibold' : ''">{{
|
|
modelValue.length }}</span><template v-if="max">/{{ max }}</template>
|
|
</div>
|
|
|
|
<div v-for="section in allSections" :key="section.key" class="space-y-2">
|
|
<!-- Section header doubles as select-all (indeterminate when partial) -->
|
|
<label class="flex items-center gap-2 text-sm font-medium cursor-pointer">
|
|
<Checkbox :model-value="sectionState(section)" :disabled="disabled"
|
|
@update:model-value="(v) => toggleSection(section, v)">
|
|
<Minus v-if="sectionState(section) === 'indeterminate'" class="size-3" />
|
|
<Check v-else class="size-3.5" />
|
|
</Checkbox>
|
|
{{ section.label }}
|
|
</label>
|
|
<div class="flex flex-col gap-1.5 pl-6">
|
|
<label v-for="cc in section.countries" :key="section.key + '-' + cc"
|
|
class="flex items-center gap-2 text-sm min-w-0"
|
|
:class="countryDisabled(cc) ? 'opacity-50' : 'cursor-pointer'"
|
|
:title="!isChecked(cc) && !hasProbes(cc) ? t('globalping.NoProbe') : ''">
|
|
<Checkbox :model-value="isChecked(cc)" :disabled="countryDisabled(cc)"
|
|
@update:model-value="(v) => setCountry(cc, v)" />
|
|
<Icon :icon="'circle-flags:' + cc.toLowerCase()" class="shrink-0 size-3.5" />
|
|
<span class="truncate">{{ countryName(cc) }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
// Reusable country picker for Globalping-backed tools (MtrTest /
|
|
// GlobalLatencyTest / CensorshipCheck): the caller's suggestion sections on
|
|
// top, followed by the full per-continent catalog of countries that
|
|
// currently have an online probe (from the session-cached inventory). All
|
|
// sections share one selection (v-model) — a country listed in several
|
|
// sections is one entry, every matching checkbox reflects it.
|
|
//
|
|
// Selecting is never hard-capped: past `max` the tally numerator turns red
|
|
// and the parent is expected to gate its run button. Suggested countries
|
|
// without an online probe are disabled up front; while the inventory loads
|
|
// everything stays enabled (fail-open — Globalping partially allocates, so
|
|
// a stale "available" only costs one empty location).
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { useMainStore } from '@/store';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { getProbeInventory } from '@/utils/features/globalping-probes.js';
|
|
import getCountryName from '@/data/country-name.js';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Icon } from '@iconify/vue';
|
|
import { Check, Minus } from '@lucide/vue';
|
|
|
|
const props = defineProps({
|
|
// Suggestion sections shown above the continent catalog:
|
|
// [{ key, label, countries }] — label arrives pre-translated.
|
|
sections: { type: Array, default: () => [] },
|
|
// Selected country codes (shared across all sections).
|
|
modelValue: { type: Array, required: true },
|
|
disabled: { type: Boolean, default: false },
|
|
// Soft cap: colors the tally, never blocks checking.
|
|
max: { type: Number, default: null },
|
|
});
|
|
const emit = defineEmits(['update:modelValue']);
|
|
|
|
const { t } = useI18n();
|
|
const store = useMainStore();
|
|
const lang = computed(() => store.lang);
|
|
const countryName = (cc) => getCountryName(cc, lang.value);
|
|
|
|
const CONTINENT_LABEL_KEYS = {
|
|
asia: 'Asia',
|
|
europe: 'Europe',
|
|
africa: 'Africa',
|
|
americas: 'Americas',
|
|
oceania: 'Oceania',
|
|
};
|
|
|
|
const inventory = ref(null);
|
|
onMounted(() => {
|
|
getProbeInventory()
|
|
.then((data) => { inventory.value = data; })
|
|
.catch(() => { /* keep fail-open */ });
|
|
});
|
|
const hasProbes = (cc) => inventory.value === null || inventory.value.countries.has(cc);
|
|
|
|
const allSections = computed(() => [
|
|
...props.sections,
|
|
...(inventory.value?.continents || []).map((c) => ({
|
|
key: 'continent-' + c.key,
|
|
label: t('globalping.' + CONTINENT_LABEL_KEYS[c.key]),
|
|
countries: c.countries,
|
|
})),
|
|
]);
|
|
|
|
const isChecked = (cc) => props.modelValue.includes(cc);
|
|
const countryDisabled = (cc) => props.disabled || (!isChecked(cc) && !hasProbes(cc));
|
|
const setCountry = (cc, checked) => {
|
|
if (checked) {
|
|
if (!isChecked(cc) && !countryDisabled(cc)) {
|
|
emit('update:modelValue', [...props.modelValue, cc]);
|
|
}
|
|
} else {
|
|
emit('update:modelValue', props.modelValue.filter((c) => c !== cc));
|
|
}
|
|
};
|
|
|
|
// Section-level select-all operates on the section's probe-having countries.
|
|
const sectionSelectable = (section) => section.countries.filter(hasProbes);
|
|
const sectionState = (section) => {
|
|
const selectable = sectionSelectable(section);
|
|
if (selectable.length > 0 && selectable.every(isChecked)) return true;
|
|
return section.countries.some(isChecked) ? 'indeterminate' : false;
|
|
};
|
|
const toggleSection = (section, checked) => {
|
|
if (props.disabled) return;
|
|
if (checked === true) {
|
|
const missing = sectionSelectable(section).filter((cc) => !isChecked(cc));
|
|
emit('update:modelValue', [...props.modelValue, ...missing]);
|
|
} else {
|
|
const drop = new Set(section.countries);
|
|
emit('update:modelValue', props.modelValue.filter((cc) => !drop.has(cc)));
|
|
}
|
|
};
|
|
</script>
|