up
This commit is contained in:
@@ -13,5 +13,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lastUpdated": 1776081278156
|
"lastUpdated": 1776083682738
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,12 @@
|
|||||||
- `ShowOrder.vue` 详情页新增:状态快捷切换按钮(四个状态一键切换)、状态变更 commit 历史时间线(竖排列表,含状态标签+时间+评论)
|
- `ShowOrder.vue` 详情页新增:状态快捷切换按钮(四个状态一键切换)、状态变更 commit 历史时间线(竖排列表,含状态标签+时间+评论)
|
||||||
- i18n 同步新增 8 个翻译 key
|
- i18n 同步新增 8 个翻译 key
|
||||||
|
|
||||||
|
### 新增:状态变更弹窗(含备注输入)
|
||||||
|
- `ShowOrder.vue` 中状态按钮不再直接变更,改为弹出确认框
|
||||||
|
- 弹窗包含:目标状态标签 + 备注 textarea(支持 Ctrl+Enter 快捷确认)
|
||||||
|
- 备注内容通过 `updateOrderStatus(id, status, comment)` 提交
|
||||||
|
- `message.save_success` 已补充到中英 i18n 文件
|
||||||
|
|
||||||
### 注意事项
|
### 注意事项
|
||||||
- 重启后端后 GORM AutoMigrate 会自动新增字段和表,无需手动 SQL
|
- 重启后端后 GORM AutoMigrate 会自动新增字段和表,无需手动 SQL
|
||||||
- 前端 `CostItem` 的 CurrencyType/Type 改为 int,与后端一致
|
- 前端 `CostItem` 的 CurrencyType/Type 改为 int,与后端一致
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"ops/models"
|
"ops/models"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -71,6 +72,7 @@ type TabPurchaseCommit struct {
|
|||||||
Status string `gorm:"size:50;comment:变更后的状态"`
|
Status string `gorm:"size:50;comment:变更后的状态"`
|
||||||
OldStatus string `gorm:"size:50;comment:变更前的状态"`
|
OldStatus string `gorm:"size:50;comment:变更前的状态"`
|
||||||
Comment string `gorm:"type:text;comment:评论/备注"`
|
Comment string `gorm:"type:text;comment:评论/备注"`
|
||||||
|
Photos string `gorm:"type:text;comment:变更图片(JSON数组,存放sha256哈希)"`
|
||||||
IP string `gorm:"size:50;comment:操作IP"`
|
IP string `gorm:"size:50;comment:操作IP"`
|
||||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||||
}
|
}
|
||||||
@@ -142,11 +144,56 @@ func ApiPurchase(r *gin.RouterGroup) {
|
|||||||
var commits []TabPurchaseCommit
|
var commits []TabPurchaseCommit
|
||||||
models.DB.Where("order_id = ?", from.ID).Order("created_at DESC").Find(&commits)
|
models.DB.Where("order_id = ?", from.ID).Order("created_at DESC").Find(&commits)
|
||||||
|
|
||||||
|
// 解析每条 commit 的 Photos JSON 字段为数组
|
||||||
|
type CommitResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
OrderID uint `json:"orderId"`
|
||||||
|
UserID uint `json:"userId"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
OldStatus string `json:"oldStatus"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
Photos []string `json:"photos"`
|
||||||
|
}
|
||||||
|
var commitResps []CommitResponse
|
||||||
|
for _, c := range commits {
|
||||||
|
// Status 优先用数据库字段;若为空(历史旧数据),从 Comment 备注中截取状态
|
||||||
|
status := c.Status
|
||||||
|
if status == "" {
|
||||||
|
status = strings.TrimPrefix(c.Comment, "状态变更为: ")
|
||||||
|
status = strings.TrimPrefix(status, "变更状态为: ")
|
||||||
|
// 如果截取后跟原文一样,说明不是"状态变更为"格式,取原文作为展示
|
||||||
|
if status == c.Comment {
|
||||||
|
status = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp := CommitResponse{
|
||||||
|
ID: c.ID,
|
||||||
|
OrderID: c.OrderID,
|
||||||
|
UserID: c.UserID,
|
||||||
|
Action: c.Action,
|
||||||
|
Status: status,
|
||||||
|
OldStatus: c.OldStatus,
|
||||||
|
Comment: c.Comment,
|
||||||
|
IP: c.IP,
|
||||||
|
CreatedAt: time.Time{},
|
||||||
|
}
|
||||||
|
if c.CreatedAt != nil {
|
||||||
|
resp.CreatedAt = *c.CreatedAt
|
||||||
|
}
|
||||||
|
if c.Photos != "" {
|
||||||
|
json.Unmarshal([]byte(c.Photos), &resp.Photos)
|
||||||
|
}
|
||||||
|
commitResps = append(commitResps, resp)
|
||||||
|
}
|
||||||
|
|
||||||
ReturnJson(ctx, "apiOK", gin.H{
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
"order": order,
|
"order": order,
|
||||||
"costs": costs,
|
"costs": costs,
|
||||||
"photos": files,
|
"photos": files,
|
||||||
"commits": commits,
|
"commits": commitResps,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,9 +206,10 @@ func ApiPurchase(r *gin.RouterGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FromUpdateStatus struct {
|
type FromUpdateStatus struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Status string `json:"status" binding:"required"`
|
Status string `json:"status" binding:"required"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
Photos []string `json:"photos"` // 变更附带的图片哈希
|
||||||
}
|
}
|
||||||
var from FromUpdateStatus
|
var from FromUpdateStatus
|
||||||
if err := mapstructure.Decode(data, &from); err != nil || from.ID == 0 {
|
if err := mapstructure.Decode(data, &from); err != nil || from.ID == 0 {
|
||||||
@@ -169,6 +217,14 @@ func ApiPurchase(r *gin.RouterGroup) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验图片哈希(不包含标点符号的哈希值)
|
||||||
|
for _, hash := range from.Photos {
|
||||||
|
if models.IsContainsSpecialChar(hash) {
|
||||||
|
ReturnJson(ctx, "photo_hash_invalid", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 校验状态值
|
// 校验状态值
|
||||||
validStatuses := map[string]bool{
|
validStatuses := map[string]bool{
|
||||||
"pending": true,
|
"pending": true,
|
||||||
@@ -204,9 +260,15 @@ func ApiPurchase(r *gin.RouterGroup) {
|
|||||||
|
|
||||||
// 写状态变更 commit
|
// 写状态变更 commit
|
||||||
comment := from.Comment
|
comment := from.Comment
|
||||||
if comment == "" {
|
if comment == "" && len(from.Photos) == 0 {
|
||||||
comment = "状态变更为: " + from.Status
|
comment = "状态变更为: " + from.Status
|
||||||
}
|
}
|
||||||
|
photosJSON := ""
|
||||||
|
if len(from.Photos) > 0 {
|
||||||
|
if pj, err := json.Marshal(from.Photos); err == nil {
|
||||||
|
photosJSON = string(pj)
|
||||||
|
}
|
||||||
|
}
|
||||||
commit := TabPurchaseCommit{
|
commit := TabPurchaseCommit{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
@@ -214,6 +276,7 @@ func ApiPurchase(r *gin.RouterGroup) {
|
|||||||
Status: from.Status,
|
Status: from.Status,
|
||||||
OldStatus: oldStatus,
|
OldStatus: oldStatus,
|
||||||
Comment: comment,
|
Comment: comment,
|
||||||
|
Photos: photosJSON,
|
||||||
IP: ctx.ClientIP(),
|
IP: ctx.ClientIP(),
|
||||||
}
|
}
|
||||||
models.DB.Create(&commit)
|
models.DB.Create(&commit)
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export const purchaseApi = {
|
|||||||
return api.post('/purchase/getorder', { id })
|
return api.post('/purchase/getorder', { id })
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 更新订单状态(可附带评论) */
|
/** 更新订单状态(可附带评论和图片) */
|
||||||
updateOrderStatus(id, status, comment = '') {
|
updateOrderStatus(id, status, comment = '', photos = []) {
|
||||||
return api.post('/purchase/updatestatus', { id, status, comment })
|
return api.post('/purchase/updatestatus', { id, status, comment, photos })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,12 @@
|
|||||||
"no_costs": "No cost records",
|
"no_costs": "No cost records",
|
||||||
"cost_total": "Total",
|
"cost_total": "Total",
|
||||||
"change_status": "Change Status",
|
"change_status": "Change Status",
|
||||||
|
"change_remark": "Change Remark",
|
||||||
|
"status_photos": "Change Photos",
|
||||||
"commit_history": "Status History",
|
"commit_history": "Status History",
|
||||||
"no_commits": "No status records",
|
"no_commits": "No status records",
|
||||||
"commit_placeholder": "Add comment (optional)",
|
"commit_placeholder": "Add comment (optional)",
|
||||||
|
"upload_photos": "Upload Photos",
|
||||||
"commit_create": "Order created"
|
"commit_create": "Order created"
|
||||||
},
|
},
|
||||||
"purchase_addorder": {
|
"purchase_addorder": {
|
||||||
@@ -222,7 +225,8 @@
|
|||||||
"type_new_pass": "Enter new password",
|
"type_new_pass": "Enter new password",
|
||||||
"type_cof_pass": "Confirm new password",
|
"type_cof_pass": "Confirm new password",
|
||||||
"old_pass_incorrect": "Old password is incorrect",
|
"old_pass_incorrect": "Old password is incorrect",
|
||||||
"confirm_password_incorrect": "Confirm password is incorrect"
|
"confirm_password_incorrect": "Confirm password is incorrect",
|
||||||
|
"save_success": "Saved successfully"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@@ -91,9 +91,12 @@
|
|||||||
"no_costs": "暂无费用记录",
|
"no_costs": "暂无费用记录",
|
||||||
"cost_total": "合计",
|
"cost_total": "合计",
|
||||||
"change_status": "变更状态",
|
"change_status": "变更状态",
|
||||||
|
"change_remark": "变更备注",
|
||||||
|
"status_photos": "变更图片",
|
||||||
"commit_history": "状态记录",
|
"commit_history": "状态记录",
|
||||||
"no_commits": "暂无状态记录",
|
"no_commits": "暂无状态记录",
|
||||||
"commit_placeholder": "添加备注(可选)",
|
"commit_placeholder": "添加备注(可选)",
|
||||||
|
"upload_photos": "上传图片",
|
||||||
"commit_create": "订单创建"
|
"commit_create": "订单创建"
|
||||||
},
|
},
|
||||||
"purchase_addorder": {
|
"purchase_addorder": {
|
||||||
@@ -222,7 +225,8 @@
|
|||||||
"type_new_pass": "输入新密码",
|
"type_new_pass": "输入新密码",
|
||||||
"type_cof_pass": "确认新密码",
|
"type_cof_pass": "确认新密码",
|
||||||
"old_pass_incorrect": "旧密码不正确",
|
"old_pass_incorrect": "旧密码不正确",
|
||||||
"confirm_password_incorrect": "确认密码不正确"
|
"confirm_password_incorrect": "确认密码不正确",
|
||||||
|
"save_success": "保存成功"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ function goToPage(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function jumpToOrder(id) {
|
function jumpToOrder(id) {
|
||||||
const resolved = router.resolve({ path: `/purchase/showorder/${id}` })
|
// const resolved = router.resolve({ path: `/purchase/showorder/${id}` })
|
||||||
window.open(resolved.href, '_blank')
|
// window.open(resolved.href, '_blank')
|
||||||
|
|
||||||
|
router.replace(`/purchase/showorder/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
@@ -121,7 +123,7 @@ onMounted(fetchOrders)
|
|||||||
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">No.</th>
|
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">No.</th>
|
||||||
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.item_name') }}</th>
|
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.item_name') }}</th>
|
||||||
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.purpose') }}</th>
|
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.purpose') }}</th>
|
||||||
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.quantity') }}</th>
|
|
||||||
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('purchase.created_at') }}</th>
|
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('purchase.created_at') }}</th>
|
||||||
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.status') }}</th>
|
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.status') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,155 +1,284 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from "vue";
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter, RouterLink } from "vue-router";
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from "vue-i18n";
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { usePageTitle } from '@/composables/usePageTitle'
|
import { usePageTitle } from "@/composables/usePageTitle";
|
||||||
import { purchaseApi } from '@/api/purchase'
|
import { purchaseApi } from "@/api/purchase";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
import {
|
import {
|
||||||
IconChevronLeft, IconExternalLink, IconPhoto,
|
IconChevronLeft,
|
||||||
IconCheck, IconLoader2
|
IconExternalLink,
|
||||||
} from '@tabler/icons-vue'
|
IconPhoto,
|
||||||
|
IconCheck,
|
||||||
|
IconLoader2,
|
||||||
|
IconX,
|
||||||
|
IconUpload,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-vue";
|
||||||
|
|
||||||
usePageTitle('purchase.order_detail')
|
usePageTitle("purchase.order_detail");
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n();
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const toast = useToastStore()
|
const toast = useToastStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const orderId = computed(() => parseInt(route.params.id))
|
const orderId = computed(() => parseInt(route.params.id));
|
||||||
|
|
||||||
const order = ref(null)
|
const order = ref(null);
|
||||||
const costs = ref([])
|
const costs = ref([]);
|
||||||
const photos = ref([])
|
const photos = ref([]);
|
||||||
const commits = ref([])
|
const commits = ref([]);
|
||||||
const loading = ref(true)
|
const loading = ref(true);
|
||||||
const notFound = ref(false)
|
const notFound = ref(false);
|
||||||
const updatingStatus = 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 = [
|
const statusOptions = [
|
||||||
{ value: 'pending', labelKey: 'status_pending', color: 'yellow' },
|
{ value: "pending", labelKey: "status_pending", color: "yellow" },
|
||||||
{ value: 'ordered', labelKey: 'status_ordered', color: 'blue' },
|
{ value: "ordered", labelKey: "status_ordered", color: "blue" },
|
||||||
{ value: 'arrived', labelKey: 'status_arrived', color: 'purple' },
|
{ value: "arrived", labelKey: "status_arrived", color: "purple" },
|
||||||
{ value: 'received', labelKey: 'status_received', color: 'green' },
|
{ value: "received", labelKey: "status_received", color: "green" },
|
||||||
]
|
];
|
||||||
|
|
||||||
// 状态颜色映射
|
// 状态颜色映射
|
||||||
const statusColorClass = computed(() => ({
|
const statusColorClass = computed(() => ({
|
||||||
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400',
|
pending:
|
||||||
ordered: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
|
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400",
|
||||||
arrived: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
|
ordered: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
||||||
received: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-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",
|
||||||
|
}));
|
||||||
|
|
||||||
// 货币选项
|
// 货币选项
|
||||||
const currencyOptions = { 1: 'CNY', 2: 'MOP', 3: 'HKD', 4: 'USD' }
|
const currencyOptions = { 1: "CNY", 2: "MOP", 3: "HKD", 4: "USD" };
|
||||||
|
|
||||||
// 费用类型映射
|
// 费用类型映射
|
||||||
const costTypeMap = computed(() => ({
|
const costTypeMap = computed(() => ({
|
||||||
1: t('cost_type.unit_price'),
|
1: t("cost_type.unit_price"),
|
||||||
2: t('cost_type.freight'),
|
2: t("cost_type.freight"),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// 合计费用
|
// 合计费用
|
||||||
const costTotalYuan = computed(() => {
|
const costTotalYuan = computed(() => {
|
||||||
return costs.value.reduce((sum, c) => sum + (c.Price || 0) * (c.Quantity || 0), 0) / 100
|
return (
|
||||||
})
|
costs.value.reduce(
|
||||||
|
(sum, c) => sum + (c.Price || 0) * (c.Quantity || 0),
|
||||||
|
0,
|
||||||
|
) / 100
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 按货币分组统计
|
// 按货币分组统计
|
||||||
const costsByCurrency = computed(() => {
|
const costsByCurrency = computed(() => {
|
||||||
const groups = {}
|
const groups = {};
|
||||||
costs.value.forEach(c => {
|
costs.value.forEach((c) => {
|
||||||
const cur = currencyOptions[c.CurrencyType] || 'Unknown'
|
const cur = currencyOptions[c.CurrencyType] || "Unknown";
|
||||||
const amount = c.Price && c.Quantity ? (c.Price * c.Quantity / 100).toFixed(2) : '0.00'
|
const amount =
|
||||||
if (!groups[cur]) groups[cur] = 0
|
c.Price && c.Quantity
|
||||||
groups[cur] += parseFloat(amount)
|
? ((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]) => ({
|
return Object.entries(groups).map(([currency, total]) => ({
|
||||||
currency,
|
currency,
|
||||||
total: total.toFixed(2),
|
total: total.toFixed(2),
|
||||||
}))
|
}));
|
||||||
})
|
});
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return "-";
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr);
|
||||||
if (isNaN(d.getTime())) return '-'
|
if (isNaN(d.getTime())) return "-";
|
||||||
return new Intl.DateTimeFormat(locale.value, {
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
year: "numeric",
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
month: "2-digit",
|
||||||
}).format(d)
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(priceInCents) {
|
function formatPrice(priceInCents) {
|
||||||
if (!priceInCents) return '0.00'
|
if (!priceInCents) return "0.00";
|
||||||
return (priceInCents / 100).toFixed(2)
|
return (priceInCents / 100).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPhotoUrl(file) {
|
function getPhotoUrl(file) {
|
||||||
return `/api/files/get/${file.Sha256}`
|
return `/api/files/get/${file.Sha256}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLink() {
|
function openLink() {
|
||||||
if (!order.value?.Link) return
|
if (!order.value?.Link) return;
|
||||||
let url = order.value.Link.trim()
|
let url = order.value.Link.trim();
|
||||||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url
|
if (!/^https?:\/\//i.test(url)) url = "https://" + url;
|
||||||
window.open(url, '_blank')
|
window.open(url, "_blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(status) {
|
function getStatusLabel(status) {
|
||||||
const opt = statusOptions.find(o => o.value === status)
|
if (!status) return "";
|
||||||
return opt ? t('purchase.' + opt.labelKey) : status
|
const opt = statusOptions.find((o) => o.value === status);
|
||||||
|
return opt ? t("purchase." + opt.labelKey) || status : status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColorClass(status) {
|
function getStatusColorClass(status) {
|
||||||
return statusColorClass.value[status] || 'bg-gray-100 text-gray-600'
|
return statusColorClass.value[status] || "bg-gray-100 text-gray-600";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStatusChange(newStatus) {
|
function openStatusDialog(newStatus) {
|
||||||
if (newStatus === order.value?.OrderStatus) return
|
if (newStatus === order.value?.OrderStatus) return;
|
||||||
updatingStatus.value = true
|
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 {
|
try {
|
||||||
const { errCode } = await purchaseApi.updateOrderStatus(orderId.value, newStatus, '')
|
const { errCode } = await purchaseApi.updateOrderStatus(
|
||||||
|
orderId.value,
|
||||||
|
pendingStatus.value,
|
||||||
|
pendingComment.value,
|
||||||
|
photoHashes,
|
||||||
|
);
|
||||||
if (errCode === 0) {
|
if (errCode === 0) {
|
||||||
order.value.OrderStatus = newStatus
|
order.value.OrderStatus = pendingStatus.value;
|
||||||
toast.success(t('message.save_success') || 'Saved')
|
toast.success(t("message.save_success"));
|
||||||
await fetchOrder() // 刷新 commit 记录
|
await fetchOrder();
|
||||||
} else {
|
} else {
|
||||||
toast.error(t('message.server_error'))
|
toast.error(t("message.server_error"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('message.server_error'))
|
toast.error(t("message.server_error"));
|
||||||
} finally {
|
} finally {
|
||||||
updatingStatus.value = false
|
updatingStatus.value = false;
|
||||||
|
pendingStatus.value = "";
|
||||||
|
pendingComment.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOrder() {
|
async function fetchOrder() {
|
||||||
loading.value = true
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { errCode, data } = await purchaseApi.getOrder(orderId.value)
|
const { errCode, data } = await purchaseApi.getOrder(orderId.value);
|
||||||
if (errCode === 0 && data) {
|
if (errCode === 0 && data) {
|
||||||
order.value = data.order ?? null
|
order.value = data.order ?? null;
|
||||||
costs.value = data.costs ?? []
|
costs.value = data.costs ?? [];
|
||||||
photos.value = data.photos ?? []
|
photos.value = data.photos ?? [];
|
||||||
commits.value = data.commits ?? []
|
commits.value = data.commits ?? [];
|
||||||
} else {
|
} else {
|
||||||
notFound.value = true
|
notFound.value = true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
notFound.value = true
|
notFound.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchOrder)
|
onMounted(fetchOrder);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-6xl px-6 py-6">
|
<div class="mx-auto max-w-6xl px-6 py-6">
|
||||||
|
|
||||||
<!-- 返回按钮 -->
|
<!-- 返回按钮 -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
@@ -157,33 +286,54 @@ onMounted(fetchOrder)
|
|||||||
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"
|
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" />
|
<IconChevronLeft :size="16" />
|
||||||
{{ t('purchase.back_to_list') }}
|
{{ t("purchase.back_to_list") }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-24">
|
<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">
|
<svg
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
class="h-8 w-8 animate-spin text-blue-500"
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
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>
|
</svg>
|
||||||
<span class="ml-3 text-gray-500">Loading...</span>
|
<span class="ml-3 text-gray-500">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Not Found -->
|
<!-- 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">
|
<div
|
||||||
<p class="text-gray-400">{{ t('purchase.order_not_found') }}</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Order Detail -->
|
<!-- Order Detail -->
|
||||||
<div v-else class="flex flex-col gap-6">
|
<div v-else class="flex flex-col gap-6">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
<div
|
||||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
|
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">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{{ t('purchase.order_detail') }} #{{ orderId }}
|
{{ t("purchase.order_detail") }} #{{ orderId }}
|
||||||
</h2>
|
</h2>
|
||||||
<!-- 当前状态标签 -->
|
<!-- 当前状态标签 -->
|
||||||
<span
|
<span
|
||||||
@@ -191,86 +341,129 @@ onMounted(fetchOrder)
|
|||||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||||
:class="getStatusColorClass(order.OrderStatus)"
|
:class="getStatusColorClass(order.OrderStatus)"
|
||||||
>
|
>
|
||||||
<span v-if="updatingStatus" class="inline-flex items-center gap-1">
|
<span
|
||||||
|
v-if="updatingStatus"
|
||||||
|
class="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
<IconLoader2 :size="10" class="animate-spin" />
|
<IconLoader2 :size="10" class="animate-spin" />
|
||||||
</span>
|
</span>
|
||||||
{{ getStatusLabel(order.OrderStatus) }}
|
{{ getStatusLabel(order.OrderStatus) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-400">{{ formatDate(order?.CreatedAt) }}</span>
|
<span class="text-sm text-gray-400">{{
|
||||||
|
formatDate(order?.CreatedAt)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 状态快捷切换按钮 -->
|
<!-- 状态快捷切换按钮 -->
|
||||||
<div class="flex flex-wrap gap-2 border-b border-gray-100 px-6 py-3 dark:border-dk-muted">
|
<div
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ t('purchase.change_status') }}:</span>
|
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
|
<button
|
||||||
v-for="opt in statusOptions"
|
v-for="opt in statusOptions"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
class="inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all"
|
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
|
:class="
|
||||||
? [getStatusColorClass(opt.value), 'border-transparent']
|
order?.OrderStatus === opt.value
|
||||||
: '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'"
|
? [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"
|
:disabled="updatingStatus"
|
||||||
@click="handleStatusChange(opt.value)"
|
@click="openStatusDialog(opt.value)"
|
||||||
>
|
>
|
||||||
<IconCheck v-if="order?.OrderStatus === opt.value" :size="12" />
|
<IconCheck v-if="order?.OrderStatus === opt.value" :size="12" />
|
||||||
{{ t('purchase.' + opt.labelKey) }}
|
{{ t("purchase." + opt.labelKey) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Info -->
|
<!-- Order Info -->
|
||||||
<div class="space-y-4 px-6 py-5">
|
<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>
|
<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 class="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('purchase_addorder.part_name') }}</label>
|
<label class="mb-1 block text-xs font-medium text-gray-400">{{
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ order?.Title || '-' }}</p>
|
t("purchase_addorder.part_name")
|
||||||
|
}}</label>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ order?.Title || "-" }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('purchase.link') }}</label>
|
<label class="mb-1 block text-xs font-medium text-gray-400">{{
|
||||||
|
t("purchase.link")
|
||||||
|
}}</label>
|
||||||
<div v-if="order?.Link" class="flex items-center gap-2">
|
<div v-if="order?.Link" class="flex items-center gap-2">
|
||||||
<p class="max-w-xs truncate text-blue-600 dark:text-blue-400">{{ order.Link }}</p>
|
<p class="max-w-xs truncate text-blue-600 dark:text-blue-400">
|
||||||
|
{{ order.Link }}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20"
|
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20"
|
||||||
@click="openLink"
|
@click="openLink"
|
||||||
>
|
>
|
||||||
<IconExternalLink :size="14" />
|
<IconExternalLink :size="14" />
|
||||||
{{ t('purchase.open_link') }}
|
{{ t("purchase.open_link") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-gray-400">-</p>
|
<p v-else class="text-gray-400">-</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order?.Styles">
|
<div v-if="order?.Styles">
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('purchase_addorder.style_remarks') }}</label>
|
<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>
|
<p class="text-gray-700 dark:text-gray-200">{{ order.Styles }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order?.Remark" class="sm:col-span-2">
|
<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>
|
<label class="mb-1 block text-xs font-medium text-gray-400">{{
|
||||||
<p class="whitespace-pre-wrap text-gray-700 dark:text-gray-200">{{ order.Remark }}</p>
|
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>
|
</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="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">
|
<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>
|
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("purchase.cost_detail") }}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="costs.length" class="overflow-x-auto px-0">
|
<div v-if="costs.length" class="overflow-x-auto px-0">
|
||||||
<table class="w-full text-left text-sm">
|
<table class="w-full text-left text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-100 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
|
<tr
|
||||||
<th class="px-6 py-3 font-medium">{{ t('purchase_addorder.fee_type') }}</th>
|
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.quantity') }}</th>
|
>
|
||||||
<th class="px-6 py-3 font-medium">{{ t('purchase.unit_price') }}</th>
|
<th class="px-6 py-3 font-medium">
|
||||||
<th class="px-6 py-3 font-medium">{{ t('purchase.total_price') }}</th>
|
{{ t("purchase_addorder.fee_type") }}
|
||||||
<th class="px-6 py-3 font-medium">{{ t('purchase_addorder.currency') }}</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -282,22 +475,31 @@ onMounted(fetchOrder)
|
|||||||
<td class="px-6 py-3 font-medium text-gray-800 dark:text-white">
|
<td class="px-6 py-3 font-medium text-gray-800 dark:text-white">
|
||||||
{{ costTypeMap[item.CostType] || item.CostType }}
|
{{ costTypeMap[item.CostType] || item.CostType }}
|
||||||
</td>
|
</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">
|
||||||
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">{{ formatPrice(item.Price) }}</td>
|
{{ 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">
|
<td class="px-6 py-3 font-medium text-gray-900 dark:text-white">
|
||||||
{{ formatPrice(item.Price * item.Quantity) }}
|
{{ formatPrice(item.Price * item.Quantity) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">
|
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">
|
||||||
{{ currencyOptions[item.CurrencyType] || '-' }}
|
{{ currencyOptions[item.CurrencyType] || "-" }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="bg-gray-50 dark:bg-dk-base">
|
<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">
|
<td
|
||||||
{{ t('purchase.cost_total') }}
|
colspan="3"
|
||||||
|
class="px-6 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ t("purchase.cost_total") }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-3 text-base font-bold text-gray-900 dark:text-white">
|
<td
|
||||||
|
class="px-6 py-3 text-base font-bold text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
{{ costTotalYuan.toFixed(2) }}
|
{{ costTotalYuan.toFixed(2) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-3">
|
<td class="px-6 py-3">
|
||||||
@@ -315,65 +517,18 @@ onMounted(fetchOrder)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="px-6 py-8 text-center text-gray-400">
|
<div v-else class="px-6 py-8 text-center text-gray-400">
|
||||||
{{ t('purchase.no_costs') }}
|
{{ t("purchase.no_costs") }}
|
||||||
</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-4 py-3"
|
|
||||||
>
|
|
||||||
<!-- 时间线点 -->
|
|
||||||
<div class="mt-1 flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="h-2.5 w-2.5 rounded-full"
|
|
||||||
:class="{
|
|
||||||
'bg-yellow-400': commit.Status === 'pending',
|
|
||||||
'bg-blue-400': commit.Status === 'ordered',
|
|
||||||
'bg-purple-400': commit.Status === 'arrived',
|
|
||||||
'bg-green-400': commit.Status === 'received',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</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="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>
|
|
||||||
</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>
|
||||||
|
|
||||||
<!-- 图片备注 -->
|
<!-- 图片备注 -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
<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">
|
<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>
|
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("purchase.photo_remarks") }}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="photos.length" class="flex flex-wrap gap-3 px-6 py-5">
|
<div v-if="photos.length" class="flex flex-wrap gap-3 px-6 py-5">
|
||||||
@@ -383,7 +538,7 @@ onMounted(fetchOrder)
|
|||||||
:href="getPhotoUrl(photo)"
|
:href="getPhotoUrl(photo)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="group relative block overflow-hidden rounded-lg border border-gray-200 dark:border-dk-muted"
|
class="group relative block overflow-hidden rounded-lg border border-gray-200 dark:border-dk-muted"
|
||||||
style="width: 120px; height: 120px;"
|
style="width: 120px; height: 120px"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="getPhotoUrl(photo)"
|
:src="getPhotoUrl(photo)"
|
||||||
@@ -393,12 +548,284 @@ onMounted(fetchOrder)
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col items-center justify-center gap-2 px-6 py-10 text-gray-400">
|
<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" />
|
<IconPhoto :size="32" class="opacity-40" />
|
||||||
<span class="text-sm">{{ t('purchase.no_photos') }}</span>
|
<span class="text-sm">{{ t("purchase.no_photos") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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-4 py-3"
|
||||||
|
>
|
||||||
|
<!-- 时间线点 -->
|
||||||
|
<div class="mt-1 flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="h-2.5 w-2.5 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-yellow-400': commit.status === 'pending',
|
||||||
|
'bg-blue-400': commit.status === 'ordered',
|
||||||
|
'bg-purple-400': commit.status === 'arrived',
|
||||||
|
'bg-green-400': commit.status === 'received',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</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="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>
|
||||||
</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.save_ok") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user