This commit is contained in:
Viet An
2026-06-07 09:22:53 +07:00
parent d17bc0583c
commit ee914cc382
8 changed files with 653 additions and 385 deletions

View File

@@ -132,9 +132,9 @@ $class-types: (
), ),
); );
// ─── Numeric: w-0 → w-48, h-0 → h-48, size-0 → size-48 ─────────────────── // ─── Numeric: w-0 → w-96, h-0 → h-96, size-0 → size-96 ───────────────────
@each $prefix, $props in $class-types { @each $prefix, $props in $class-types {
@for $i from 0 through 48 { @for $i from 0 through 96 {
.#{$prefix}-#{$i} { .#{$prefix}-#{$i} {
@include set-props($props, calc(var(--spacing) * #{$i})); @include set-props($props, calc(var(--spacing) * #{$i}));
} }
@@ -341,9 +341,9 @@ $inset-types: (
), ),
); );
// ─── Numeric: 0 → 48, using the same --spacing scale ────────────────────── // ─── Numeric: 0 → 96, using the same --spacing scale ──────────────────────
@each $prefix, $props in $inset-types { @each $prefix, $props in $inset-types {
@for $i from 0 through 48 { @for $i from 0 through 96 {
.#{$prefix}-#{$i} { .#{$prefix}-#{$i} {
@include set-props($props, calc(var(--spacing) * #{$i})); @include set-props($props, calc(var(--spacing) * #{$i}));
} }
@@ -375,3 +375,98 @@ $inset-keywords: (
} }
} }
} }
// ─── Max/Min width & height ────────────────────────────────────────────────
$minmax-types: (
"max-w": (
max-width,
),
"min-w": (
min-width,
),
"max-h": (
max-height,
),
"min-h": (
min-height,
),
);
// ─── Numeric: 0 → 96, using --spacing scale ───────────────────────────────
@each $prefix, $props in $minmax-types {
@for $i from 0 through 96 {
.#{$prefix}-#{$i} {
@include set-props($props, calc(var(--spacing) * #{$i}));
}
}
}
// ─── Fractions ─────────────────────────────────────────────────────────────
@each $prefix, $props in $minmax-types {
@each $name, $pair in $fractions {
$num: list.nth($pair, 1);
$den: list.nth($pair, 2);
.#{$prefix}-#{$name} {
@include set-props($props, calc(#{$num} / #{$den} * 100%));
}
}
}
// ─── Shared keywords ───────────────────────────────────────────────────────
@each $prefix, $props in $minmax-types {
@each $name, $value in $shared-keywords {
.#{$prefix}-#{$name} {
@include set-props($props, $value);
}
}
}
// ─── Viewport keywords ─────────────────────────────────────────────────────
@each $prefix, $props in $minmax-types {
@each $name, $value in $viewport-keywords {
.#{$prefix}-#{$name} {
@include set-props($props, $value);
}
}
}
// ─── Container sizes (max-w- / min-w- only) ───────────────────────────────
@each $name in $containers {
.max-w-#{$name} {
max-width: var(--container-#{$name});
}
.min-w-#{$name} {
min-width: var(--container-#{$name});
}
}
// ─── Screen ────────────────────────────────────────────────────────────────
.max-w-screen {
max-width: 100vw;
}
.min-w-screen {
min-width: 100vw;
}
.max-h-screen {
max-height: 100vh;
}
.min-h-screen {
min-height: 100vh;
}
// ─── none (max only) ───────────────────────────────────────────────────────
.max-w-none {
max-width: none;
}
.max-h-none {
max-height: none;
}
// ─── Opacity ───────────────────────────────────────────────────────────────
$opacity-values: (0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100);
@each $val in $opacity-values {
.opacity-#{$val} {
opacity: calc($val / 100);
}
}

View File

@@ -1,33 +1,59 @@
<template> <template>
<div> <div>
<div> <div class="block rounded-md is-clipped">
<video <video
ref="video" ref="video"
id="video"
width="640"
height="480" height="480"
autoplay autoplay
class="w-full is-block"
></video> ></video>
</div> </div>
<div class="mt-2"> <div class="fixed-grid has-12-cols mb-0">
<button <div class="grid">
class="button is-primary" <div class="cell is-flex is-justify-content-center is-align-items-center">
id="snap" <button
v-on:click="capture()" @click="$emit('close')"
> class="button is-light rounded-full"
Chụp ảnh >
</button> <span class="icon">
<a <Icon
class="ml-6" name="material-symbols:close-rounded"
@click="switchView()" :size="20"
> />
<SvgIcon v-bind="{ name: 'camera_switch.svg', type: 'black', size: 40 }"></SvgIcon> </span>
</a> </button>
</div>
<div class="cell is-col-span-10 has-text-centered">
<button
@click="capture"
class="button is-primary is-medium rounded-full"
>
<span class="icon">
<Icon
name="material-symbols:photo-camera-outline-rounded"
:size="24"
/>
</span>
</button>
</div>
<div class="cell is-flex is-justify-content-center is-align-items-center">
<button
@click="flip"
class="button is-light is-primary rounded-full"
>
<span class="icon">
<Icon
name="material-symbols:flip-camera-ios-outline-rounded"
:size="20"
/>
</span>
</button>
</div>
</div>
</div> </div>
<canvas <canvas
v-show="false" v-show="false"
ref="canvas" ref="canvas"
id="canvas"
width="640" width="640"
height="480" height="480"
></canvas> ></canvas>
@@ -35,14 +61,14 @@
</template> </template>
<script> <script>
export default { export default {
data: function () { data() {
return { return {
video: {}, video: {},
canvas: {}, canvas: {},
current: "front", current: "front",
}; };
}, },
mounted: function () { mounted() {
this.openCamera(); this.openCamera();
}, },
beforeDestroy() { beforeDestroy() {
@@ -54,46 +80,61 @@ export default {
}, },
methods: { methods: {
openCamera() { openCamera() {
let f = this.current === "front" ? { facingMode: "user" } : { facingMode: { exact: "environment" } };
this.video = this.$refs.video; this.video = this.$refs.video;
const facingConstraint = {
facingMode: this.current === "front" ? "user" : { exact: "environment" },
};
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices.getUserMedia({ video: f, audio: false }).then((stream) => { navigator.mediaDevices
video.srcObject = stream; .getUserMedia({ video: facingConstraint, audio: false })
this.video.play(); .then((stream) => {
}); this.video.srcObject = stream;
this.video.play();
})
.catch((err) => {
if (this.current === "back" && (err.name === "OverconstrainedError" || err.name === "NotFoundError")) {
this.current = "front";
this.$snackbar("Không tìm thấy camera sau", "Error");
this.openCamera(); // retry front cam
}
});
} }
}, },
capture() { capture() {
this.canvas = this.$refs.canvas; this.canvas = this.$refs.canvas;
let scale = 600 / this.video.videoWidth; const scale = 600 / this.video.videoWidth;
let w = this.video.videoWidth * scale; const w = this.video.videoWidth * scale;
let h = this.video.videoHeight * scale; const h = this.video.videoHeight * scale;
this.canvas.width = w; this.canvas.width = w;
this.canvas.height = h; this.canvas.height = h;
var context = this.canvas.getContext("2d").drawImage(this.video, 0, 0, w, h); this.canvas.getContext("2d").drawImage(this.video, 0, 0, w, h);
this.canvas.toBlob((blod) => this.saveAs(blod)); this.canvas.toBlob((blob) => this.saveAs(blob));
}, },
async saveAs(blod) { async saveAs(blob) {
var form = new FormData(); const form = new FormData();
let name = `${this.$id()}.png`; const name = `${this.$id()}.png`;
this.fileName = `${this.$dayjs(new Date()).format("YYYYMMDDhhmmss")}-${name}`; this.fileName = `${this.$dayjs(new Date()).format("YYYYMMDDhhmmss")}-${name}`;
form.append("filename", this.fileName); form.append("filename", this.fileName);
form.append("name", name); form.append("name", name);
form.append("file", blod); form.append("file", blob);
form.append("type", "image"); form.append("type", "image");
form.append("size", 100); form.append("size", 100);
form.append("user", this.$store.state.login.id); form.append("user", this.$store.login.id || 1);
let result = await this.$insertapi("upload", { data: form });
const result = await this.$insertapi("upload", { data: form });
if (result === "error") return; if (result === "error") return;
let row = result.rows[0];
const file = new File([blod], name, { type: "image/png" }); const row = result.rows[0];
row.source = { file: file }; const file = new File([blob], name, { type: "image/png" });
row.source = { file };
this.$emit("modalevent", { name: "selectimage", data: row }); this.$emit("modalevent", { name: "selectimage", data: row });
this.$emit("close"); this.$emit("close");
}, },
switchView() { flip() {
this.current = this.current === "front" ? "back" : "front"; this.current = this.current === "front" ? "back" : "front";
var vidTrack = this.video.srcObject.getVideoTracks(); const vidTrack = this.video.srcObject.getVideoTracks();
vidTrack.forEach((track) => { vidTrack.forEach((track) => {
track.stop(); track.stop();
track.enabled = false; track.enabled = false;

View File

@@ -1,66 +1,97 @@
<template> <template>
<div <div
class="tile is-ancestor py-5 px-3 mx-0"
v-if="image" v-if="image"
class="fixed-grid has-12-cols"
> >
<div class="tile is-1" /> <div class="grid is-gap-2">
<div class="tile is-7"> <div class="cell is-col-span-8">
<Cropper <Cropper
ref="cropper" ref="cropper"
:src="image" :src="image"
@change="onChange" :canvas="false"
:stencil-props="getRatio" @change="onChange"
/> :stencil-props="getRatio"
</div> />
<div class="tile is-1"></div>
<div class="tile">
<div v-if="avatar !== true">
<p class="mt-2 fs-16">Chọn tỷ lệ hoặc nhập chiều rộng cao</p>
<div class="tags are-medium mt-2">
<a
:class="curRatio.k === v.k ? 'tag is-primary' : 'tag'"
v-for="(v, i) in ratios"
:key="i"
@click="curRatio = v"
>{{ v.k }}</a
>
</div>
<div class="block mt-5">
<b-radio
v-model="radio"
native-value="replace"
>
Ghi đè
</b-radio>
<b-radio
v-model="radio"
class="ml-3"
native-value="new"
>
Tạo file mới
</b-radio>
</div>
<p class="mt-4">
<a
class="button is-primary mr-4 mb-2"
:class="loading ? 'is-loading' : ''"
@click="updateImage()"
>Lưu lại</a
>
</p>
<p
class="mt-2"
v-if="coordinates"
>
Hình ảnh cắt,
{{ "W: " + coordinates.width + ", H: " + coordinates.height + ", W/H: " + coordinates.ratio }}
</p>
</div> </div>
<div <div class="cell is-col-span-4">
class="is-italic has-text-grey" <div
v-else v-if="avatar"
> class="is-italic has-text-grey"
* Di chuyển khung để chọn hình ảnh phù hợp >
* Di chuyển khung để chọn hình ảnh phù hợp
</div>
<div v-else>
<p class="label">Chọn tỷ lệ:</p>
<div class="fixed-grid has-12-cols">
<div class="grid is-gap-0.5">
<div
class="cell is-col-span-3"
v-for="ratio in ratios"
:key="ratio"
>
<button
:class="['button is-fullwidth is-small', curRatio === ratio ? 'is-primary' : 'is-light']"
@click="curRatio = ratio"
>
{{ ratio }}
</button>
</div>
</div>
</div>
<hr />
<div
v-if="coordinates"
class="fixed-grid has-3-cols is-family-monospace"
>
<div class="grid">
<div class="cell p-2 rounded has-background-white-ter has-text-centered">
<p class="fs-11 is-uppercase has-text-grey">width</p>
<p class="font-semibold">{{ coordinates.width }}</p>
</div>
<div class="cell p-2 rounded has-background-white-ter has-text-centered">
<p class="fs-11 is-uppercase has-text-grey">height</p>
<p class="font-semibold">{{ coordinates.height }}</p>
</div>
<div class="cell p-2 rounded has-background-white-ter has-text-centered">
<p class="fs-11 is-uppercase has-text-grey">ratio</p>
<p class="font-semibold">{{ coordinates.ratio }}</p>
</div>
</div>
</div>
<hr />
<div class="block is-flex is-gap-1 is-flex-wrap-wrap">
<button
v-for="v in [
{ name: 'Ghi đè', value: 'replace' },
{ name: 'Tạo file mới', value: 'new' },
]"
:key="v.value"
@click="radio = v.value"
:class="['button fs-14', radio === v.value ? 'is-primary is-light' : 'is-white']"
>
<span class="icon">
<Icon
:name="
radio === v.value
? 'material-symbols:radio-button-checked-outline'
: 'material-symbols:radio-button-unchecked'
"
:size="18"
/>
</span>
<span>{{ v.name }}</span>
</button>
</div>
<div class="block">
<button
class="button is-primary"
:class="loading && 'is-loading'"
@click="updateImage"
>
Lưu lại
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -68,6 +99,7 @@
<script> <script>
import { CircleStencil, Cropper } from "vue-advanced-cropper"; import { CircleStencil, Cropper } from "vue-advanced-cropper";
import "vue-advanced-cropper/dist/style.css"; import "vue-advanced-cropper/dist/style.css";
export default { export default {
components: { components: {
Cropper, Cropper,
@@ -78,61 +110,67 @@ export default {
return { return {
coordinates: undefined, coordinates: undefined,
ratios: [ ratios: [
{ k: "1/1" }, "1/1",
{ k: "5/4" }, "5/4",
{ k: "4/3" }, "4/3",
{ k: "3/2" }, "3/2",
{ k: "5/3" }, "5/3",
{ k: "16/9" }, "16/9",
{ k: "2/1" }, "2/1",
{ k: "3/1" }, "3/1",
{ k: "4/5" }, "4/5",
{ k: "3/4" }, "3/4",
{ k: "2/3" }, "2/3",
{ k: "3/5" }, "3/5",
{ k: "9/16" }, "9/16",
{ k: "1/2" }, "1/2",
{ k: "1/3" }, "1/3",
], ],
curRatio: { k: "1/1" }, curRatio: "1/1",
radio: this.avatar ? "new" : "replace", radio: this.avatar ? "new" : "replace",
rectangle: true,
loading: false, loading: false,
}; };
}, },
computed: { computed: {
getRatio() { getRatio() {
return { aspectRatio: this.$calc(this.curRatio.k) }; return { aspectRatio: this.$calc(this.curRatio) };
}, },
}, },
methods: { methods: {
onChange({ coordinates, canvas }) { onChange({ coordinates }) {
this.coordinates = coordinates; this.coordinates = coordinates;
this.coordinates.ratio = ((this.coordinates.width * 1.0) / this.coordinates.height).toFixed(2); this.coordinates.ratio = (this.coordinates.width / this.coordinates.height).toFixed(2);
}, },
updateImage() { updateImage() {
const { canvas } = this.$refs.cropper.getResult(); const { canvas } = this.$refs.cropper.getResult();
if (canvas) canvas.toBlob((blod) => this.saveAs(blod)); if (canvas) canvas.toBlob((blob) => this.saveAs(blob));
}, },
async saveAs(blod) { async saveAs(blob) {
this.loading = true; try {
var form = new FormData(); this.loading = true;
let name = const form = new FormData();
this.selected.file.indexOf("-") > 0 const name =
? this.selected.file.substring(15, this.selected.file.length) this.selected.file.indexOf("-") > 0
: this.selected.file; ? this.selected.file.substring(15, this.selected.file.length)
this.fileName = this.$dayjs(new Date()).format("YYYYMMDDhhmmss") + "-" + name; : this.selected.file;
if (this.radio === "replace") this.fileName = this.selected.file; this.fileName = this.$dayjs(new Date()).format("YYYYMMDDhhmmss") + "-" + name;
form.append("filename", this.fileName); if (this.radio === "replace") this.fileName = this.selected.file;
form.append("name", name);
form.append("file", blod); form.append("filename", this.fileName);
form.append("type", "file"); form.append("name", name);
form.append("size", this.selected.size); form.append("file", blob);
form.append("user", this.$store.state.login.id); form.append("type", "file");
let result = await this.$insertapi("upload", { data: form }); form.append("size", this.selected.size);
this.loading = false; form.append("user", this.$store.login.id);
if (result === "error") return;
this.$emit("modalevent", { name: "image", data: result.rows[0] }); const result = await this.$insertapi("upload", { data: form });
if (result === "error") return;
this.$emit("modalevent", { name: "image", data: result.rows[0] });
} catch (error) {
console.error(error);
} finally {
this.loading = false;
}
}, },
}, },
}; };

View File

@@ -1,31 +1,31 @@
<template> <template>
<div v-if="files"> <div v-if="files">
<div <div
class="has-text-right"
v-if="!hideopt && $getEditRights()" v-if="!hideopt && $getEditRights()"
class="has-text-right"
> >
<FileUpload <FileUpload
v-bind="{ type: ['file'], position }" v-bind="{ type: ['file'], position }"
@files="getFiles" @files="getFiles"
></FileUpload> />
</div> </div>
<div> <div>
<div <div
v-if="files.length === 0 && info"
class="mt-3 has-text-grey fs-15" class="mt-3 has-text-grey fs-15"
v-if="files.length === 0 && info !== false"
> >
Chưa tài liệu được tải lên Chưa tài liệu được tải lên
</div> </div>
<DataView <DataView
v-bind="vbind"
v-else-if="vbind" v-else-if="vbind"
></DataView> v-bind="vbind"
/>
</div> </div>
<Modal <Modal
@close="showmodal = undefined"
v-bind="showmodal"
v-if="showmodal" v-if="showmodal"
></Modal> v-bind="showmodal"
@close="showmodal = undefined"
/>
</div> </div>
</template> </template>
<script> <script>

View File

@@ -10,144 +10,147 @@
<div class="control"> <div class="control">
<div class="file is-primary"> <div class="file is-primary">
<label class="file-label"> <label class="file-label">
<button :class="['button is-primary is-small', loading && 'is-loading']">
<span class="icon">
<Icon
name="material-symbols:upload-rounded"
:size="18"
/>
</span>
<span>Tải lên từ máy tính</span>
</button>
<input <input
class="file-input"
type="file" type="file"
:id="docid" @change="doChange"
multiple multiple
name="resume" name="resume"
@change="doChange" class="file-input is-clickable"
:id="docid"
/> />
<a
class="button is-primary is-rounded is-small"
:class="loading ? 'is-loading' : null"
>
<SvgIcon v-bind="{ name: 'add5.svg', type: 'white', size: 18 }"></SvgIcon>
<span class="fs-14 ml-1">Tải lên từ máy tính</span>
</a>
</label> </label>
</div> </div>
</div> </div>
<div :class="`control ${showUrl ? '' : 'is-expanded'}`"> <div :class="['control', !showUrl && 'is-expanded']">
<a <button
class="button is-dark is-rounded is-small" class="button is-small"
@click="displayInput()" @click="displayInput"
> >
<SvgIcon v-bind="{ name: 'add5.svg', type: 'white', size: 18 }"></SvgIcon> <span class="icon">
<span class="fs-14 ml-1">Tải lên từ đường dẫn</span> <Icon
</a> name="material-symbols:link-rounded"
:size="18"
/>
</span>
<span>Tải lên từ đường dẫn</span>
</button>
</div> </div>
<div <div
class="control is-expanded"
v-if="showUrl" v-if="showUrl"
class="field has-addons is-flex-grow-1 mb-0"
> >
<input <div class="control">
class="input is-small is-rounded" <input
id="url" class="input is-small w-60"
v-model="url" id="url"
style="width: 250px" v-model.trim="url"
type="text" type="text"
placeholder="Nhập đường dẫn vào đây" placeholder="Nhập đường dẫn"
/> />
<a </div>
class="button is-primary is-small px-4 ml-3" <div class="control">
@click="checkUrl()" <button
> class="button is-light is-small rounded-full"
<SvgIcon v-bind="{ name: 'upload.svg', type: 'white', size: 22 }"></SvgIcon> @click="showUrl = false"
</a> >
<a <span class="icon">
class="ml-4" <Icon
@click="showUrl = false" name="material-symbols:close-rounded"
> :size="18"
<SvgIcon v-bind="{ name: 'close.svg', type: 'dark', size: 22 }"></SvgIcon> />
</a> </span>
</button>
</div>
<div class="control">
<button
class="button is-primary is-small"
:disabled="!url"
@click="checkUrl"
>
<span class="icon">
<Icon
name="material-symbols:arrow-forward-rounded"
:size="20"
/>
</span>
</button>
</div>
</div> </div>
<div class="control"> <div class="control has-icons-left">
<input <input
class="input is-small is-rounded"
v-model="search" v-model="search"
style="width: 250px"
type="text" type="text"
placeholder="Tìm kiếm"
@keyup="beginSearch" @keyup="beginSearch"
class="input is-small is-rounded w-2xs"
placeholder="Tìm kiếm"
/> />
</div>
<div class="control">
<span <span
class="is-clickable" class="icon is-small is-left"
@click="mode = 'image'" style="left: 2px"
> >
<SvgIcon v-bind="{ name: 'image3.svg', type: 'dark', size: 25 }"></SvgIcon> <Icon
name="material-symbols:search"
:size="18"
/>
</span> </span>
</div> </div>
<div class="control"> <div class="tabs is-toggle m-0">
<span <ul class="is-flex-grow-0 ml-auto">
class="is-clickable" <li
@click="mode = 'list'" v-for="viewMode in viewModes"
> :key="viewMode.name"
<SvgIcon v-bind="{ name: 'list.png', type: 'dark', size: 25 }"></SvgIcon> :class="[viewMode.name === mode && 'is-active']"
</span> @click="mode = viewMode.name"
>
<a class="px-3 py-1">
<span class="icon m-0">
<Icon
:name="viewMode.icon"
:size="18"
/>
</span>
</a>
</li>
</ul>
</div> </div>
</div> </div>
<DataView <DataView
class="mt-3"
v-bind="vbind"
v-if="mode === 'list'" v-if="mode === 'list'"
></DataView> v-bind="vbind"
<div class="mt-3"
class="tile is-ancestor mx-0 px-0 pt-3" />
v-else <div v-else>
> <div
<div class="tile is-vertical"> class="fixed-grid has-5-cols"
<div v-for="(_, i) in group"
class="tile is-parent" :key="i"
v-for="(v, i) in group" >
:key="i" <div class="grid">
> <div
<article
class="tile is-child"
v-for="(k, j) in getData(i)" v-for="(k, j) in getData(i)"
:key="j" :key="j"
@mouseover="focus = k" @mouseover="focus = k"
@mouseleave="focus = undefined"
class="cell relative rounded-md is-clipped"
> >
<div <figure
class="image px-2 pb-2"
v-if="k.file && type === 'image'" v-if="k.file && type === 'image'"
class="image is-square mx-auto is-flex is-align-items-center"
> >
<NuxtImg <NuxtImg
:src="`${path}download?name=${k.file}`" :src="`${path}download?name=${k.file}`"
:id="'commentImage' + k.id" :id="'commentImage' + k.id"
/> />
<div </figure>
class="text-image"
v-if="focus === k"
>
<a
class="button is-primary is-small"
@click="selectMedia(k)"
>
<SvgIcon v-bind="{ name: 'checked.svg', type: 'white', size: 22 }"></SvgIcon>
</a>
<a
class="button is-primary is-small ml-2"
@click="editImage(k)"
>
<SvgIcon v-bind="{ name: 'crop.svg', type: 'white', size: 22 }"></SvgIcon>
</a>
<a
class="button is-primary is-small ml-2"
@click="copyMedia(k, 'image')"
>
<SvgIcon v-bind="{ name: 'copy.svg', type: 'white', size: 22 }"></SvgIcon>
</a>
<a
class="button is-danger is-small ml-2"
@click="deleteMedia(k, 'file')"
>
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 22 }"></SvgIcon>
</a>
</div>
</div>
<div <div
class="ml-2 mr-2" class="ml-2 mr-2"
v-else-if="k.file && type === 'video'" v-else-if="k.file && type === 'video'"
@@ -164,12 +167,17 @@
class="mt-2" class="mt-2"
v-if="focus === k" v-if="focus === k"
> >
<a <button
class="button is-primary" class="button is-primary"
@click="selectMedia(k)" @click="selectMedia(k)"
> >
<SvgIcon v-bind="{ name: 'check3.svg', type: 'primary', size: 22 }"></SvgIcon> <span class="icon">
</a> <Icon
name="material-symbols:check-rounded"
:size="18"
/>
</span>
</button>
</div> </div>
</vue-plyr> </vue-plyr>
</div> </div>
@@ -182,28 +190,88 @@
class="mt-2" class="mt-2"
v-if="focus === k" v-if="focus === k"
> >
<a <button
class="button is-primary" class="button is-primary"
@click="selectMedia(k)" @click="selectMedia(k)"
> >
<SvgIcon v-bind="{ name: 'check3.svg', type: 'primary', size: 22 }"></SvgIcon> <span class="icon">
</a> <Icon
name="material-symbols:check-rounded"
:size="18"
/>
</span>
</button>
</div> </div>
</div> </div>
</article> <div
v-if="focus === k"
class="absolute top-0 size-full"
>
<div class="size-full has-background-black opacity-15"></div>
<div class="buttons has-addons absolute left-0 right-0 bottom-2 mx-auto w-fit">
<button
class="button is-small is-white"
@click="selectMedia(k)"
>
<span class="icon">
<Icon
name="material-symbols:check-rounded"
:size="18"
/>
</span>
</button>
<button
class="button is-small is-white"
@click="editImage(k)"
>
<span class="icon">
<Icon
name="material-symbols:crop-free-rounded"
:size="18"
/>
</span>
</button>
<button
class="button is-small is-white"
@click="copyMedia(k)"
>
<span class="icon">
<Icon
name="material-symbols:content-copy-outline-rounded"
:size="18"
/>
</span>
</button>
<button
class="button is-small is-white"
@click="deleteMedia(k, 'file')"
>
<span class="icon">
<Icon
name="material-symbols:delete-outline-rounded"
:size="18"
class="has-text-danger"
/>
</span>
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<Modal <Modal
@close="showmodal = undefined" v-if="showmodal"
v-bind="showmodal" v-bind="showmodal"
@close="showmodal = undefined"
@image="updateImage" @image="updateImage"
@files="getFiles" @files="getFiles"
v-if="showmodal"
/> />
</div> </div>
</template> </template>
<script> <script>
import DataView from "~/components/datatable/DataView.vue";
export default { export default {
props: ["source"], props: ["source"],
data() { data() {
@@ -222,6 +290,10 @@ export default {
url: undefined, url: undefined,
search: undefined, search: undefined,
mode: "image", mode: "image",
viewModes: [
{ name: "list", icon: "material-symbols:format-list-bulleted-rounded" },
{ name: "image", icon: "material-symbols:grid-view-outline-rounded" },
],
timer: undefined, timer: undefined,
vbind: { api: "file", setting: "image-fields" }, vbind: { api: "file", setting: "image-fields" },
dataFiles: [], dataFiles: [],
@@ -230,13 +302,15 @@ export default {
}; };
}, },
async created() { async created() {
let found = this.$findapi("file"); const fileApi = this.$findapi("file");
found.params = { fileApi.params = {
filter: { user: this.login.id },
sort: "-create_time", sort: "-create_time",
type__code: this.type, filter: {
user: this.login.id,
type__code: this.type,
},
}; };
let result = await this.$getapi([found]); const result = await this.$getapi([fileApi]);
this.data = result[0].data.rows; this.data = result[0].data.rows;
}, },
watch: { watch: {
@@ -298,7 +372,7 @@ export default {
this.timer = setTimeout(() => this.startSearch(e.target.value), 150); this.timer = setTimeout(() => this.startSearch(e.target.value), 150);
}, },
async startSearch(value) { async startSearch(value) {
const filter = { user: this.login.id }; const filter = { user: this.login.id, type__code: this.type };
if (!this.$empty(value)) filter.name__icontains = value.toLowerCase(); if (!this.$empty(value)) filter.name__icontains = value.toLowerCase();
this.data = await this.$getdata("file", { filter }); this.data = await this.$getdata("file", { filter });
}, },
@@ -307,29 +381,25 @@ export default {
this.url = undefined; this.url = undefined;
setTimeout(() => document.getElementById("url").focus(), 100); setTimeout(() => document.getElementById("url").focus(), 100);
}, },
checkUrl() { async checkUrl() {
if (this.$empty(this.url)) return this.$snackbar("Đường dẫn không hợp lệ", "Error"); if (this.$empty(this.url)) return this.$snackbar("Đường dẫn không hợp lệ", "Error");
let self = this;
this.loading = true; try {
this.$axios const self = this;
.get(this.url, { responseType: "blob" }) this.loading = true;
.then(function (response) { const res = await $fetch(this.url);
var reader = new window.FileReader(); const reader = new window.FileReader();
reader.onload = (e) => { reader.onload = (e) => {
self.image = e.target.result; self.image = e.target.result;
setTimeout(() => self.doUpload(e.target.result), 100); setTimeout(() => self.doUpload(e.target.result), 100);
}; };
reader.readAsDataURL(response.data); reader.readAsDataURL(res);
self.loading = false; } catch (error) {
}) this.$snackbar("Đường dẫn không hợp lệ", "Error");
.catch((e) => { console.error(error);
self.$buefy.toast.open({ } finally {
duration: 3000, this.loading = false;
message: `Đường dẫn không hợp lệ`, }
type: "is-danger",
});
self.loading = false;
});
}, },
doUpload() { doUpload() {
const image = document.getElementById("image"); const image = document.getElementById("image");
@@ -340,102 +410,103 @@ export default {
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
if (canvas) canvas.toBlob((blod) => this.saveAs(blod)); if (canvas) canvas.toBlob((blod) => this.saveAs(blod));
}, },
async saveAs(blod) { async saveAs(blob) {
var form = new FormData(); const form = new FormData();
const fileName = this.$dayjs(new Date()).format("YYYYMMDDhhmmss") + "-" + this.$id() + ".png"; const fileName = this.$dayjs(new Date()).format("YYYYMMDDhhmmss") + "-" + this.$id() + ".png";
form.append("filename", fileName); form.append("filename", fileName);
form.append("name", `${this.$id()}.png`); form.append("name", `${this.$id()}.png`);
form.append("file", blod); form.append("file", blob);
form.append("type", "image"); form.append("type", "image");
form.append("size", 100); form.append("size", 100);
form.append("user", this.$store.login.id); form.append("user", this.$store.login.id);
let result = await this.$insertapi("upload", { data: form });
const result = await this.$insertapi("upload", { data: form });
if (result === "error") return; if (result === "error") return;
this.updateImage(result.rows[0]); this.updateImage(result.rows[0]);
this.showUrl = false; this.showUrl = false;
}, },
async uploadImage(file) { async uploadImage(file) {
this.loading = true; this.loading = true;
let result = await this.$insertapi("upload", { data: file.form }); const result = await this.$insertapi("upload", { data: file.form });
this.data.splice(0, 0, result.rows[0]); this.data.splice(0, 0, result.rows[0]);
this.loading = false; this.loading = false;
}, },
getData(i) { getData(i) {
if (this.type === "video") { const list = this.data.slice(i * 5, i * 5 + 5);
var list = this.data.slice(i * 5, i * 5 + 5); for (let index = 0; index < 5; index++) {
for (let index = 0; index < 5; index++) { if (list.length < index + 1) list.push({ file: undefined });
if (list.length < index + 1) list.push({ file: undefined });
}
} else {
list = this.data.slice(i * 6, i * 6 + 6);
for (let index = 0; index < 6; index++) {
if (list.length < index + 1) list.push({ file: undefined });
}
} }
return list; return list;
}, },
selectMedia(v) { async selectMedia(v) {
let copy = this.media ? this.$copy(this.media) : {}; const copy = this.media ? this.$copy(this.media) : {};
copy.type = "image"; copy.type = "image";
copy.open = false; copy.open = false;
copy.select = v; copy.select = v;
this.media = copy; this.media = copy;
let row = this.$copy(v); const row = this.$copy(v);
if (this.source) { if (this.source) {
let params = { name: v.file, type: "file" }; const res = await $fetch(`${this.path}download/`, {
this.$axios params: {
.get(`${this.path}download/`, { name: v.file,
params: params, type: "file",
responseType: "blob", },
}) });
.then(function (response) {
var reader = new window.FileReader(); const reader = new window.FileReader();
reader.onload = (e) => { reader.onload = (e) => {
fetch(e.target.result) fetch(e.target.result)
.then((res) => res.blob()) .then((res) => res.blob())
.then((blob) => { .then((blob) => {
const file = new File([blob], v.name, { type: "image/png" }); const file = new File([blob], v.name, { type: "image/png" });
row.source = { file: file }; row.source = { file };
}); });
}; };
reader.readAsDataURL(response.data); reader.readAsDataURL(res);
});
} }
this.$emit("modalevent", { name: "selectimage", data: row }); this.$emit("modalevent", { name: "selectimage", data: row });
}, },
editImage(v) { async editImage(v) {
this.loading = true; this.loading = true;
this.selected = v; this.selected = v;
let self = this; const self = this;
let params = { name: v.file, type: "file" };
this.$axios.get(`${this.path}download/`, { params: params, responseType: "blob" }).then(function (response) { const res = await $fetch(`${this.path}download/`, {
var reader = new window.FileReader(); params: {
reader.onload = (e) => { name: v.file,
self.image = e.target.result; type: "file",
self.showmodal = { },
component: "media/CropImage",
width: "65%",
title: "Cắt hình ảnh",
vbind: { selected: self.selected, image: self.image },
};
};
reader.readAsDataURL(response.data);
self.loading = false;
}); });
const reader = new window.FileReader();
reader.onload = (e) => {
self.image = e.target.result;
self.showmodal = {
component: "media/CropImage",
title: "Cắt hình ảnh",
width: "90%",
height: "auto",
vbind: { selected: self.selected, image: self.image },
};
};
reader.readAsDataURL(res);
self.loading = false;
}, },
copyMedia(v) { copyMedia(v) {
this.$copyToClipboard(`${this.path}download/?name=${v.file}`); this.$copyToClipboard(`${this.path}download/?name=${v.file}`);
}, },
deleteMedia(v, name) { deleteMedia(v, name) {
let self = this; let self = this;
var remove = async function () { const remove = async function () {
let result = await self.$deleteapi(name, v.id); await self.$deleteapi(name, v.id);
let idx = self.data.findIndex((x) => x.id === v.id); const idx = self.data.findIndex((x) => x.id === v.id);
self.$delete(self.data, idx); self.$delete(self.data, idx);
}; };
this.$buefy.dialog.confirm({ this.$buefy.dialog.confirm({
message: "Bạn muốn xóa file: " + v.file, message: "Bạn muốn xóa file: " + v.file,
onConfirm: () => remove(), onConfirm: remove,
}); });
}, },
updateImage(v) { updateImage(v) {
@@ -445,3 +516,10 @@ export default {
}, },
}; };
</script> </script>
<style scoped>
.tabs {
--bulma-tabs-toggle-link-active-background-color: var(--bulma-link-90);
--bulma-tabs-toggle-link-active-border-color: var(--bulma-link-90);
--bulma-tabs-toggle-link-active-color: var(--bulma-link-40);
}
</style>

View File

@@ -1,39 +1,55 @@
<template> <template>
<div> <div class="is-flex is-gap-1">
<div <div
class="image-container"
v-if="picture" v-if="picture"
class="image is-128x128 relative"
> >
<img <NuxtImg :src="picture" />
:src="picture" <div class="absolute top-2 right-2">
style="max-height: 250px !important" <button
/> class="button is-small is-light is-danger"
<div class="image-label"> @click="remove"
<span
class="is-clickable"
@click="remove()"
> >
<SvgIcon v-bind="{ name: 'bin.svg', type: 'danger', size: 30 }"></SvgIcon> <span class="icon">
</span> <Icon
name="material-symbols:delete-outline-rounded"
:size="20"
/>
</span>
</button>
</div> </div>
</div> </div>
<div <div
style="width: 130px; border-style: dashed; border-width: 1px"
v-else v-else
@click="openImage()"
class="size-35 rounded-md is-clickable is-flex is-justify-content-center is-align-items-center"
style="border: 1px dashed var(--bulma-grey-light)"
> >
<a @click="openImage()"><SvgIcon v-bind="{ name: 'image.svg', type: 'grey', size: 120 }"></SvgIcon></a> <Icon
name="material-symbols:add-photo-alternate-outline-rounded"
:size="50"
class="has-text-grey-light"
/>
</div> </div>
<div class="mt-2"> <div>
<a @click="openCamera()"> <button
<SvgIcon v-bind="{ name: 'camera.svg', type: 'dark', size: 28 }"></SvgIcon> class="button is-medium is-light is-primary"
</a> @click="openCamera()"
>
<span class="icon">
<Icon
name="material-symbols:photo-camera-outline-rounded"
:size="24"
/>
</span>
</button>
</div> </div>
<Modal <Modal
@close="showmodal = undefined" @close="showmodal = undefined"
v-bind="showmodal" v-bind="showmodal"
v-if="showmodal" v-if="showmodal"
@selectimage="selectImage" @selectimage="selectImage"
></Modal> />
</div> </div>
</template> </template>
<script> <script>
@@ -58,8 +74,8 @@ export default {
}, },
selectImage(files) { selectImage(files) {
this.showmodal = undefined; this.showmodal = undefined;
let v = files; const v = files;
this.picture = `${this.$path()}download/?name=${v.file__file || v.file}&type=file`; this.picture = `${this.$getpath()}download/?name=${v.file__file || v.file}&type=file`;
v.image = this.$copy(this.picture); v.image = this.$copy(this.picture);
this.vfile = v; this.vfile = v;
this.$emit("picture", v); this.$emit("picture", v);
@@ -71,8 +87,8 @@ export default {
openCamera() { openCamera() {
this.showmodal = { this.showmodal = {
component: "media/Camera", component: "media/Camera",
title: "Chụp ảnh", width: "700px",
width: "650px", height: "auto",
}; };
}, },
}, },

View File

@@ -199,7 +199,7 @@ export default {
if (files.length === 0) return; if (files.length === 0) return;
const file = files.item(0); const file = files.item(0);
let thefile = this.$upload(file, "file", 1); const thefile = this.$upload(file, "file", 1);
if (thefile.error) { if (thefile.error) {
this.$snackbar(thefile.text, "Error"); this.$snackbar(thefile.text, "Error");
@@ -214,7 +214,7 @@ export default {
this.msgInfo.push({ message, type: "error" }); this.msgInfo.push({ message, type: "error" });
return (this.isloading = false); return (this.isloading = false);
} }
let result = await this.$insertapi("upload", { data: thefile.form, notify: false }); const result = await this.$insertapi("upload", { data: thefile.form, notify: false });
if (result === "error") { if (result === "error") {
const message = this.$find( const message = this.$find(
this.$store.syspara, this.$store.syspara,

View File

@@ -140,26 +140,26 @@ export default defineNuxtPlugin((nuxtApp) => {
text: "Kích thước video phải dưới 1GB", text: "Kích thước video phải dưới 1GB",
}; };
} }
let data = new FormData(); const form = new FormData();
let fileName = dayjs(new Date()).format("YYYYMMDDhhmmss") + "-" + file.name; const filename = dayjs(new Date()).format("YYYYMMDDhhmmss") + "-" + file.name;
data.append("name", file.name); form.append("name", file.name);
data.append("filename", fileName); form.append("filename", filename);
data.append("file", file); form.append("file", file);
data.append("type", type); form.append("type", type);
data.append("size", file.size); form.append("size", file.size);
data.append("user", user); form.append("user", user);
data.append("convert", convert); form.append("convert", convert);
// Thêm quality nếu convert được bật và quality được cung cấp // Thêm quality nếu convert được bật và quality được cung cấp
if (convert && quality !== null && quality !== undefined) { if (convert && quality !== null && quality !== undefined) {
data.append("quality", quality); form.append("quality", quality);
} }
return { return {
form: data, form,
type: type, type,
size: file.size, file,
file: file, filename,
name: file.name, name: file.name,
filename: fileName, size: file.size,
}; };
}; };