Files
ops2/frontend/ops_vue_js/src/views/purchase/ShowOrder.vue
T
2026-04-23 15:50:06 +08:00

914 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { useRoute, useRouter, RouterLink } from "vue-router";
import { useI18n } from "vue-i18n";
import { useToastStore } from "@/stores/toast";
import { usePageTitle } from "@/composables/usePageTitle";
import { purchaseApi } from "@/api/purchase";
import { useUserStore } from "@/stores/user";
import { useUsersStore } from "@/stores/users";
import {
IconChevronLeft,
IconExternalLink,
IconPhoto,
IconCheck,
IconLoader2,
IconX,
IconUpload,
IconTrash,
} from "@tabler/icons-vue";
usePageTitle("purchase.order_detail");
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const usersStore = useUsersStore();
const userStore = useUserStore();
const orderId = computed(() => parseInt(route.params.id));
const order = ref(null);
const costs = ref([]);
const photos = ref([]);
const commits = ref([]);
const canModify = ref(false);
const loading = ref(true);
const notFound = ref(false);
const updatingStatus = ref(false);
const showStatusDialog = ref(false);
const pendingStatus = ref("");
const pendingComment = ref("");
// 状态变更附带的图片
const pendingPhotos = ref([]); // { hash, url, uploading, error }
const photoInputRef = ref(null);
// 状态选项
const statusOptions = [
{ value: "pending", labelKey: "status_pending", color: "yellow" },
{ value: "ordered", labelKey: "status_ordered", color: "blue" },
{ value: "arrived", labelKey: "status_arrived", color: "purple" },
{ value: "received", labelKey: "status_received", color: "green" },
{ value: "lost", labelKey: "status_lost", color: "red" },
{ value: "returned", labelKey: "status_returned", color: "gray" },
];
// 状态颜色映射
const statusColorClass = computed(() => ({
pending:
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400",
ordered: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
arrived:
"bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
received:
"bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
lost:
"bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
returned:
"bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
}));
// 货币选项
const currencyOptions = { 1: "CNY", 2: "MOP", 3: "HKD", 4: "USD" };
// 费用类型映射
const costTypeMap = computed(() => ({
1: t("cost_type.unit_price"),
2: t("cost_type.freight"),
}));
// 合计费用
const costTotalYuan = computed(() => {
return (
costs.value.reduce(
(sum, c) => sum + (c.Price || 0) * (c.Quantity || 0),
0,
) / 100
);
});
// 按货币分组统计
const costsByCurrency = computed(() => {
const groups = {};
costs.value.forEach((c) => {
const cur = currencyOptions[c.CurrencyType] || "Unknown";
const amount =
c.Price && c.Quantity
? ((c.Price * c.Quantity) / 100).toFixed(2)
: "0.00";
if (!groups[cur]) groups[cur] = 0;
groups[cur] += parseFloat(amount);
});
return Object.entries(groups).map(([currency, total]) => ({
currency,
total: total.toFixed(2),
}));
});
function formatDate(dateStr) {
if (!dateStr) return "-";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "-";
return new Intl.DateTimeFormat(locale.value, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(d);
}
function formatPrice(priceInCents) {
if (!priceInCents) return "0.00";
return (priceInCents / 100).toFixed(2);
}
function getPhotoUrl(file) {
return `/api/files/get/${file.Sha256}`;
}
function openLink() {
if (!order.value?.Link) return;
let url = order.value.Link.trim();
if (!/^https?:\/\//i.test(url)) url = "https://" + url;
window.open(url, "_blank");
}
function copyLink() {
if (!order.value?.Link) return;
navigator.clipboard.writeText(order.value.Link.trim()).then(() => {
const toast = useToastStore()
toast.success('链接已复制')
})
}
function getStatusLabel(status) {
if (!status) return "";
const opt = statusOptions.find((o) => o.value === status);
return opt ? t("purchase." + opt.labelKey) || status : status;
}
function getStatusColorClass(status) {
return statusColorClass.value[status] || "bg-gray-100 text-gray-600";
}
function openStatusDialog(newStatus) {
pendingStatus.value = newStatus;
pendingComment.value = "";
pendingPhotos.value = [];
showStatusDialog.value = true;
}
function closeStatusDialog() {
showStatusDialog.value = false;
pendingStatus.value = "";
pendingComment.value = "";
pendingPhotos.value = [];
}
// 触发文件选择
function openPhotoPicker() {
photoInputRef.value?.click();
}
// 选择文件后上传
async function handlePhotoChange(event) {
const files = Array.from(event.target.files || []);
if (!files.length) return;
event.target.value = ""; // 清空,允许重复选同一文件
for (const file of files) {
if (pendingPhotos.value.length >= 10) break;
const tempId = Date.now() + Math.random();
const entry = {
tempId,
url: URL.createObjectURL(file),
uploading: true,
error: false,
hash: null,
};
pendingPhotos.value.push(entry);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("cookie", userStore.cookieValue);
const res = await fetch("/api/files/upload/image", {
method: "POST",
body: formData,
});
const json = await res.json();
if (json.errCode === 0 || json.return?.hash) {
const p = pendingPhotos.value.find((p) => p.tempId === tempId);
if (p) {
p.hash = json.return.hash;
p.uploading = false;
}
} else {
const p = pendingPhotos.value.find((p) => p.tempId === tempId);
if (p) {
p.uploading = false;
p.error = true;
}
}
} catch {
const p = pendingPhotos.value.find((p) => p.tempId === tempId);
if (p) {
p.uploading = false;
p.error = true;
}
}
}
}
// 移除待上传的图片
function removePendingPhoto(tempId) {
const idx = pendingPhotos.value.findIndex((p) => p.tempId === tempId);
if (idx !== -1) {
const p = pendingPhotos.value[idx];
if (p.url) URL.revokeObjectURL(p.url);
pendingPhotos.value.splice(idx, 1);
}
}
async function confirmStatusChange() {
// 等所有图片上传完
await new Promise((resolve) => setTimeout(resolve, 200));
const stillUploading = pendingPhotos.value.some((p) => p.uploading);
if (stillUploading) {
toast.error("图片正在上传中,请稍候");
return;
}
const photoHashes = pendingPhotos.value
.filter((p) => !p.error)
.map((p) => p.hash);
updatingStatus.value = true;
showStatusDialog.value = false;
try {
const { errCode } = await purchaseApi.updateOrderStatus(
orderId.value,
pendingStatus.value,
pendingComment.value,
photoHashes,
);
if (errCode === 0) {
order.value.OrderStatus = pendingStatus.value;
toast.success(t("message.save_success"));
await fetchOrder();
} else {
toast.error(t("message.server_error"));
}
} catch {
toast.error(t("message.server_error"));
} finally {
updatingStatus.value = false;
pendingStatus.value = "";
pendingComment.value = "";
}
}
async function fetchOrder() {
loading.value = true;
try {
const { errCode, data } = await purchaseApi.getOrder(orderId.value);
if (errCode === 0 && data) {
order.value = data.order ?? null;
canModify.value = data.canModify ?? false;
costs.value = data.costs ?? [];
photos.value = data.photos ?? [];
commits.value = data.commits ?? [];
} else {
notFound.value = true;
}
} catch {
notFound.value = true;
} finally {
loading.value = false;
}
}
onMounted(fetchOrder);
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<!-- 顶部操作栏返回 + 编辑 -->
<div class="mb-4 flex items-center justify-between">
<RouterLink
to="/purchase"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dk-card dark:hover:text-gray-200"
>
<IconChevronLeft :size="16" />
{{ t("purchase.back_to_list") }}
</RouterLink>
<!-- 编辑按钮 -->
<RouterLink
v-if="canModify"
:to="`/purchase/editorder/${order.ID}`"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-card dark:text-gray-300 dark:hover:bg-dk-base"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ t("purchase.edit_order") }}
</RouterLink>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-24">
<svg
class="h-8 w-8 animate-spin text-blue-500"
viewBox="0 0 24 24"
fill="none"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span class="ml-3 text-gray-500">Loading...</span>
</div>
<!-- Not Found -->
<div
v-else-if="notFound"
class="rounded-xl border border-gray-200 bg-white py-16 text-center shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
<p class="text-gray-400">{{ t("purchase.order_not_found") }}</p>
</div>
<!-- Order Detail -->
<div v-else class="flex flex-col gap-6">
<!-- Header -->
<div
class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
<div
class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted"
>
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("purchase.order_detail") }} #{{ orderId }}
</h2>
<!-- 当前状态标签 -->
<span
v-if="order?.OrderStatus"
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="getStatusColorClass(order.OrderStatus)"
>
<span
v-if="updatingStatus"
class="inline-flex items-center gap-1"
>
<IconLoader2 :size="10" class="animate-spin" />
</span>
{{ getStatusLabel(order.OrderStatus) }}
</span>
<!-- 创建者 -->
<span
v-if="order?.UserID"
class="ml-1 flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-gray-500 dark:border-dk-muted dark:bg-dk-base dark:text-gray-400"
>
<img
:src="usersStore.getAvatarUrlFromUserID(order.UserID)"
class="rounded-full object-cover"
style="width:18px;height:18px;"
/>
{{ usersStore.getUsernameFromUserID(order.UserID) }}
</span>
</div>
<span class="text-sm text-gray-400">{{
formatDate(order?.CreatedAt)
}}</span>
</div>
<!-- 状态快捷切换按钮 -->
<div
class="flex flex-wrap gap-2 border-b border-gray-100 px-6 py-3 dark:border-dk-muted"
>
<span class="text-sm text-gray-500 dark:text-gray-400"
>{{ t("purchase.change_status") }}:</span
>
<button
v-for="opt in statusOptions.slice(0, 4)"
:key="opt.value"
class="inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all"
:class="
order?.OrderStatus === opt.value
? [getStatusColorClass(opt.value), 'border-transparent']
: 'border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-400 dark:hover:bg-dk-base'
"
:disabled="updatingStatus"
@click="openStatusDialog(opt.value)"
>
<IconCheck v-if="order?.OrderStatus === opt.value" :size="12" />
{{ t("purchase." + opt.labelKey) }}
</button>
<!-- 异常状态右对齐 -->
<span class="flex-1" />
<button
v-for="opt in statusOptions.slice(4)"
:key="opt.value"
class="inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all"
:class="
order?.OrderStatus === opt.value
? [getStatusColorClass(opt.value), 'border-transparent']
: 'border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-400 dark:hover:bg-dk-base'
"
:disabled="updatingStatus"
@click="openStatusDialog(opt.value)"
>
<IconCheck v-if="order?.OrderStatus === opt.value" :size="12" />
{{ t("purchase." + opt.labelKey) }}
</button>
</div>
<!-- Order Info -->
<div class="space-y-4 px-6 py-5">
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400">
{{ t("purchase.order_info") }}
</h4>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-400">{{
t("purchase_addorder.part_name")
}}</label>
<p class="font-medium text-gray-900 dark:text-white">
{{ order?.Title || "-" }}
</p>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-400">{{
t("purchase.link")
}}</label>
<div v-if="order?.Link" class="flex flex-wrap items-center gap-2">
<a
:href="order.Link.trim().startsWith('http') ? order.Link.trim() : 'https://' + order.Link.trim()"
target="_blank"
class="max-w-[400px] truncate rounded bg-gray-100 px-3 py-1.5 text-xs text-blue-600 hover:bg-gray-200 dark:bg-dk-base dark:text-blue-400 dark:hover:bg-gray-800"
:title="order.Link"
>
{{ order.Link }}
</a>
<button
class="inline-flex items-center gap-1 rounded bg-blue-100 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/60"
@click="copyLink"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{{ t('purchase.copy_link') }}
</button>
<button
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
@click="openLink"
>
<IconExternalLink :size="14" />
{{ t("purchase.open_link") }}
</button>
</div>
<p v-else class="text-gray-400">-</p>
</div>
<div v-if="order?.Styles">
<label class="mb-1 block text-xs font-medium text-gray-400">{{
t("purchase_addorder.style_remarks")
}}</label>
<p class="text-gray-700 dark:text-gray-200">{{ order.Styles }}</p>
</div>
<div v-if="order?.Remark" class="sm:col-span-2">
<label class="mb-1 block text-xs font-medium text-gray-400">{{
t("purchase_addorder.remarks")
}}</label>
<p class="whitespace-pre-wrap text-gray-700 dark:text-gray-200">
{{ order.Remark }}
</p>
</div>
</div>
</div>
</div>
<!-- 费用明细 -->
<div
class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
<div class="border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400">
{{ t("purchase.cost_detail") }}
</h4>
</div>
<div v-if="costs.length" class="overflow-x-auto px-0">
<table class="w-full text-left text-sm">
<thead>
<tr
class="border-b border-gray-100 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base"
>
<th class="px-6 py-3 font-medium">
{{ t("purchase_addorder.fee_type") }}
</th>
<th class="px-6 py-3 font-medium">
{{ t("purchase_addorder.quantity") }}
</th>
<th class="px-6 py-3 font-medium">
{{ t("purchase.unit_price") }}
</th>
<th class="px-6 py-3 font-medium">
{{ t("purchase.total_price") }}
</th>
<th class="px-6 py-3 font-medium">
{{ t("purchase_addorder.currency") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, idx) in costs"
:key="idx"
class="border-b border-gray-50 dark:border-dk-muted/50"
>
<td class="px-6 py-3 font-medium text-gray-800 dark:text-white">
{{ costTypeMap[item.CostType] || item.CostType }}
</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">
{{ item.Quantity }}
</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">
{{ formatPrice(item.Price) }}
</td>
<td class="px-6 py-3 font-medium text-gray-900 dark:text-white">
{{ formatPrice(item.Price * item.Quantity) }}
</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">
{{ currencyOptions[item.CurrencyType] || "-" }}
</td>
</tr>
</tbody>
<tfoot>
<tr class="bg-gray-50 dark:bg-dk-base">
<td
colspan="3"
class="px-6 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300"
>
{{ t("purchase.cost_total") }}
</td>
<td
class="px-6 py-3 text-base font-bold text-gray-900 dark:text-white"
>
<!-- {{ costTotalYuan.toFixed(2) }} -->
</td>
<td class="px-6 py-3">
<span
v-for="g in costsByCurrency"
:key="g.currency"
class="mr-2 inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
{{ g.currency }} {{ g.total }}
</span>
</td>
</tr>
</tfoot>
</table>
</div>
<div v-else class="px-6 py-8 text-center text-gray-400">
{{ t("purchase.no_costs") }}
</div>
</div>
<!-- 图片备注 -->
<div
class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
<div class="border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400">
{{ t("purchase.photo_remarks") }}
</h4>
</div>
<div v-if="photos.length" class="flex flex-wrap gap-3 px-6 py-5">
<a
v-for="photo in photos"
:key="photo.ID"
:href="getPhotoUrl(photo)"
target="_blank"
class="group relative block overflow-hidden rounded-lg border border-gray-200 dark:border-dk-muted"
style="width: 120px; height: 120px"
>
<img
:src="getPhotoUrl(photo)"
:alt="photo.Name || 'photo'"
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
</a>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 px-6 py-10 text-gray-400"
>
<IconPhoto :size="32" class="opacity-40" />
<span class="text-sm">{{ t("purchase.no_photos") }}</span>
</div>
</div>
<!-- 状态记录Commit History -->
<div
class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
<div class="border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400">
{{ t("purchase.commit_history") }}
</h4>
</div>
<div
v-if="commits.length"
class="divide-y divide-gray-50 px-6 py-2 dark:divide-dk-muted/50"
>
<div
v-for="commit in commits"
:key="commit.id"
class="flex items-start gap-3 py-3"
>
<!-- 左侧头像 + 用户名 -->
<div class="flex w-20 flex-shrink-0 flex-col items-center gap-1">
<img
:src="usersStore.getAvatarUrlFromUserID(commit.userId)"
class="rounded-full border border-gray-200 object-cover dark:border-dk-muted"
style="width:32px;height:32px;"
/>
<span
class="w-full truncate text-center"
style="font-size:11px;color:var(--text-secondary,#6b7280);"
:title="usersStore.getUsernameFromUserID(commit.userId)"
>
{{ usersStore.getUsernameFromUserID(commit.userId) }}
</span>
</div>
<!-- 中间时间线点 -->
<div class="flex flex-shrink-0 flex-col items-center pt-1">
<div
class="h-3 w-3 rounded-full border-2 border-white dark:border-dk-base"
:class="getStatusColorClass(commit.status)"
/>
</div>
<!-- 右侧状态 + 备注 + 图片 -->
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="getStatusColorClass(commit.status)"
>
{{ getStatusLabel(commit.status) }}
</span>
<span
v-if="commit.action === 'create'"
class="text-xs text-gray-400"
>
{{ t("purchase.commit_create") }}
</span>
<span v-else class="text-xs text-gray-400">
{{
commit.oldStatus
? getStatusLabel(commit.oldStatus) + " → "
: ""
}}{{ getStatusLabel(commit.status) }}
</span>
<span class="ml-auto text-xs text-gray-400">{{
formatDate(commit.createdAt)
}}</span>
</div>
<p
v-if="commit.comment"
class="mt-1 text-sm text-gray-600 dark:text-gray-300"
>
{{ commit.comment }}
</p>
<div
v-if="commit.photos?.length"
class="mt-2 flex flex-wrap gap-1.5"
>
<a
v-for="hash in commit.photos"
:key="hash"
:href="`/api/files/get/${hash}`"
target="_blank"
class="block overflow-hidden rounded border border-gray-200 dark:border-dk-muted transition-transform hover:scale-105"
style="width:48px;height:48px;"
>
<img
:src="`/api/files/get/${hash}`"
class="h-full w-full object-cover"
/>
</a>
</div>
</div>
</div>
</div>
<div v-else class="px-6 py-8 text-center text-sm text-gray-400">
{{ t("purchase.no_commits") }}
</div>
</div>
</div>
</div>
<!-- 状态变更弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showStatusDialog"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
@click.self="closeStatusDialog"
>
<div
class="w-full max-w-md rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-dk-muted dark:bg-dk-card"
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-gray-100 px-5 py-4 dark:border-dk-muted"
>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ t("purchase.change_status") }}
</h3>
<button
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dk-base"
@click="closeStatusDialog"
>
<IconX :size="18" />
</button>
</div>
<!-- Body -->
<div class="space-y-4 px-5 py-5">
<!-- 新状态 -->
<div>
<label
class="mb-2 block text-sm text-gray-500 dark:text-gray-400"
>
{{ t("purchase.status") }}
</label>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-semibold"
:class="getStatusColorClass(pendingStatus)"
>
<IconLoader2
v-if="updatingStatus"
:size="12"
class="animate-spin"
/>
<IconCheck v-else :size="12" />
{{ getStatusLabel(pendingStatus) }}
</span>
</div>
</div>
<!-- 变更备注 -->
<div>
<label
class="mb-2 block text-sm text-gray-500 dark:text-gray-400"
>
{{ t("purchase.change_remark") }}
</label>
<textarea
v-model="pendingComment"
rows="3"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
:placeholder="t('purchase.commit_placeholder')"
:disabled="updatingStatus"
@keydown.ctrl.enter="confirmStatusChange"
/>
</div>
<!-- 变更图片 -->
<div>
<label
class="mb-2 block text-sm text-gray-500 dark:text-gray-400"
>
{{ t("purchase.status_photos") }}
</label>
<!-- 隐藏的文件选择框 -->
<input
ref="photoInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
@change="handlePhotoChange"
/>
<!-- 已选图片预览 -->
<div
v-if="pendingPhotos.length"
class="mb-2 flex flex-wrap gap-2"
>
<div
v-for="p in pendingPhotos"
:key="p.tempId"
class="group relative overflow-hidden rounded-lg border border-gray-200 dark:border-dk-muted"
style="width: 60px; height: 60px"
>
<img :src="p.url" class="h-full w-full object-cover" />
<!-- 上传中遮罩 -->
<div
v-if="p.uploading"
class="absolute inset-0 flex items-center justify-center bg-black/40"
>
<IconLoader2 :size="16" class="animate-spin text-white" />
</div>
<!-- 失败遮罩 -->
<div
v-else-if="p.error"
class="absolute inset-0 flex items-center justify-center bg-red-500/60"
>
<IconX :size="14" class="text-white" />
</div>
<!-- 移除按钮 -->
<button
v-if="!p.uploading"
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white opacity-0 transition-opacity group-hover:opacity-100"
@click="removePendingPhoto(p.tempId)"
>
<IconX :size="10" />
</button>
</div>
</div>
<!-- 上传按钮 -->
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 px-3 py-2 text-sm text-gray-500 transition-colors hover:border-blue-400 hover:text-blue-500 disabled:opacity-50 dark:border-dk-muted dark:text-gray-400 dark:hover:border-blue-500 dark:hover:text-blue-400"
:disabled="
updatingStatus ||
pendingPhotos.filter((p) => !p.uploading).length >= 10
"
@click="openPhotoPicker"
>
<IconUpload :size="14" />
{{ t("purchase.upload_photos") }}
</button>
</div>
</div>
<!-- Footer -->
<div
class="flex justify-end gap-3 border-t border-gray-100 px-5 py-4 dark:border-dk-muted"
>
<button
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-50 disabled:opacity-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base"
:disabled="updatingStatus"
@click="closeStatusDialog"
>
{{ t("settings.cancel") }}
</button>
<button
class="flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-50"
:disabled="updatingStatus"
@click="confirmStatusChange"
>
<IconLoader2
v-if="updatingStatus"
:size="14"
class="animate-spin"
/>
{{ t("message.submit") }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>