/** * 意见反馈:校验 → 可选上传截图(最多 3 张,单张 ≤5MB)→ POST /api/feedback/add * 上游 type: 1-功能建议、2-播放问题、3-账号问题、4-其他 */ (function () { var MAX_FILES = 3; var MAX_SIZE = 5 * 1024 * 1024; var ACCEPT_TYPES = { "image/png": 1, "image/jpeg": 1, "image/jpg": 1 }; var CITY_OPTIONS = { "北京市": "北京市", "天津市": "天津市", "河北省": "石家庄市|唐山市|秦皇岛市|邯郸市|邢台市|保定市|张家口市|承德市|沧州市|廊坊市|衡水市", "山西省": "太原市|大同市|阳泉市|长治市|晋城市|朔州市|晋中市|运城市|忻州市|临汾市|吕梁市", "内蒙古自治区": "呼和浩特市|包头市|乌海市|赤峰市|通辽市|鄂尔多斯市|呼伦贝尔市|巴彦淖尔市|乌兰察布市|兴安盟|锡林郭勒盟|阿拉善盟", "辽宁省": "沈阳市|大连市|鞍山市|抚顺市|本溪市|丹东市|锦州市|营口市|阜新市|辽阳市|盘锦市|铁岭市|朝阳市|葫芦岛市", "吉林省": "长春市|吉林市|四平市|辽源市|通化市|白山市|松原市|白城市|延边朝鲜族自治州", "黑龙江省": "哈尔滨市|齐齐哈尔市|鸡西市|鹤岗市|双鸭山市|大庆市|伊春市|佳木斯市|七台河市|牡丹江市|黑河市|绥化市|大兴安岭地区", "上海市": "上海市", "江苏省": "南京市|无锡市|徐州市|常州市|苏州市|南通市|连云港市|淮安市|盐城市|扬州市|镇江市|泰州市|宿迁市", "浙江省": "杭州市|宁波市|温州市|嘉兴市|湖州市|绍兴市|金华市|衢州市|舟山市|台州市|丽水市", "安徽省": "合肥市|芜湖市|蚌埠市|淮南市|马鞍山市|淮北市|铜陵市|安庆市|黄山市|滁州市|阜阳市|宿州市|六安市|亳州市|池州市|宣城市", "福建省": "福州市|厦门市|莆田市|三明市|泉州市|漳州市|南平市|龙岩市|宁德市", "江西省": "南昌市|景德镇市|萍乡市|九江市|新余市|鹰潭市|赣州市|吉安市|宜春市|抚州市|上饶市", "山东省": "济南市|青岛市|淄博市|枣庄市|东营市|烟台市|潍坊市|济宁市|泰安市|威海市|日照市|临沂市|德州市|聊城市|滨州市|菏泽市", "河南省": "郑州市|开封市|洛阳市|平顶山市|安阳市|鹤壁市|新乡市|焦作市|濮阳市|许昌市|漯河市|三门峡市|南阳市|商丘市|信阳市|周口市|驻马店市|济源市", "湖北省": "武汉市|黄石市|十堰市|宜昌市|襄阳市|鄂州市|荆门市|孝感市|荆州市|黄冈市|咸宁市|随州市|恩施土家族苗族自治州|仙桃市|潜江市|天门市|神农架林区", "湖南省": "长沙市|株洲市|湘潭市|衡阳市|邵阳市|岳阳市|常德市|张家界市|益阳市|郴州市|永州市|怀化市|娄底市|湘西土家族苗族自治州", "广东省": "广州市|韶关市|深圳市|珠海市|汕头市|佛山市|江门市|湛江市|茂名市|肇庆市|惠州市|梅州市|汕尾市|河源市|阳江市|清远市|东莞市|中山市|潮州市|揭阳市|云浮市", "广西壮族自治区": "南宁市|柳州市|桂林市|梧州市|北海市|防城港市|钦州市|贵港市|玉林市|百色市|贺州市|河池市|来宾市|崇左市", "海南省": "海口市|三亚市|三沙市|儋州市", "重庆市": "重庆市", "四川省": "成都市|自贡市|攀枝花市|泸州市|德阳市|绵阳市|广元市|遂宁市|内江市|乐山市|南充市|眉山市|宜宾市|广安市|达州市|雅安市|巴中市|资阳市|阿坝藏族羌族自治州|甘孜藏族自治州|凉山彝族自治州", "贵州省": "贵阳市|六盘水市|遵义市|安顺市|毕节市|铜仁市|黔西南布依族苗族自治州|黔东南苗族侗族自治州|黔南布依族苗族自治州", "云南省": "昆明市|曲靖市|玉溪市|保山市|昭通市|丽江市|普洱市|临沧市|楚雄彝族自治州|红河哈尼族彝族自治州|文山壮族苗族自治州|西双版纳傣族自治州|大理白族自治州|德宏傣族景颇族自治州|怒江傈僳族自治州|迪庆藏族自治州", "西藏自治区": "拉萨市|日喀则市|昌都市|林芝市|山南市|那曲市|阿里地区", "陕西省": "西安市|铜川市|宝鸡市|咸阳市|渭南市|延安市|汉中市|榆林市|安康市|商洛市", "甘肃省": "兰州市|嘉峪关市|金昌市|白银市|天水市|武威市|张掖市|平凉市|酒泉市|庆阳市|定西市|陇南市|临夏回族自治州|甘南藏族自治州", "青海省": "西宁市|海东市|海北藏族自治州|黄南藏族自治州|海南藏族自治州|果洛藏族自治州|玉树藏族自治州|海西蒙古族藏族自治州", "宁夏回族自治区": "银川市|石嘴山市|吴忠市|固原市|中卫市", "新疆维吾尔自治区": "乌鲁木齐市|克拉玛依市|吐鲁番市|哈密市|昌吉回族自治州|博尔塔拉蒙古自治州|巴音郭楞蒙古自治州|阿克苏地区|克孜勒苏柯尔克孜自治州|喀什地区|和田地区|伊犁哈萨克自治州|塔城地区|阿勒泰地区", "香港特别行政区": "香港岛|九龙|新界", "澳门特别行政区": "澳门半岛|氹仔|路环", "台湾省": "台北市|新北市|桃园市|台中市|台南市|高雄市|基隆市|新竹市|嘉义市|新竹县|苗栗县|彰化县|南投县|云林县|嘉义县|屏东县|宜兰县|花莲县|台东县|澎湖县|金门县|连江县", "海外": "海外", }; var root = document.querySelector("[data-feedback-select]"); var form = document.querySelector("[data-feedback-form]"); var typeInput = document.querySelector("[data-feedback-type]"); var contentInput = document.querySelector("[data-feedback-content]"); var contactInput = document.querySelector("[data-feedback-contact]"); var fileInput = document.querySelector("[data-feedback-file]"); var fileText = document.querySelector("[data-feedback-file-text]"); var uploadEl = document.querySelector("[data-feedback-upload]"); var previewList = document.querySelector("[data-feedback-preview-list]"); var submitBtn = document.querySelector("[data-feedback-submit]"); var counter = document.querySelector("[data-feedback-counter]"); var typeChips = document.querySelectorAll("[data-feedback-type-option]"); var choiceGroups = document.querySelectorAll("[data-feedback-choice-group]"); var provinceSelect = document.querySelector("[data-feedback-province]"); var citySelect = document.querySelector("[data-feedback-city]"); var operatorSelect = document.querySelector("[data-feedback-operator]"); var trigger = root ? root.querySelector("[data-feedback-select-trigger]") : null; var valueEl = root ? root.querySelector("[data-feedback-select-value]") : null; var menu = root ? root.querySelector("[data-feedback-select-menu]") : null; var options = root ? root.querySelectorAll(".feedback-select-option") : []; var placeholder = valueEl ? valueEl.textContent : ""; var defaultSubmitText = submitBtn ? submitBtn.textContent : "提交反馈"; /** @type {{ id: string, file: File, url: string }[]} */ var selectedFiles = []; var fileSeq = 0; function setSelectPlaceholderState(select) { if (!select) return; select.classList.toggle("is-placeholder", !select.value); } function closeCustomSelect(wrap, restoreFocus) { if (!wrap) return; var custom = wrap._feedbackSelect; if (!custom) return; wrap.classList.remove("is-open"); custom.menu.hidden = true; custom.trigger.setAttribute("aria-expanded", "false"); if (restoreFocus) custom.trigger.focus(); } function closeAllCustomSelects(except) { document.querySelectorAll(".feedback-select-wrap.is-open").forEach(function (wrap) { if (wrap !== except) closeCustomSelect(wrap, false); }); } function focusCustomOption(menu, index) { var items = menu.querySelectorAll(".feedback-select-menu-option"); if (!items.length) return; var nextIndex = Math.max(0, Math.min(index, items.length - 1)); items[nextIndex].focus(); } function openCustomSelect(wrap) { if (!wrap) return; var custom = wrap._feedbackSelect; if (!custom || custom.select.disabled) return; closeAllCustomSelects(wrap); wrap.classList.add("is-open"); custom.menu.hidden = false; custom.trigger.setAttribute("aria-expanded", "true"); } function buildCustomSelectMenu(select) { var wrap = select && select._feedbackSelectWrap; var custom = wrap && wrap._feedbackSelect; if (!custom) return; custom.menu.innerHTML = ""; Array.prototype.forEach.call(select.options, function (nativeOption) { if (nativeOption.disabled) return; var option = document.createElement("button"); option.type = "button"; option.className = "feedback-select-menu-option"; option.textContent = nativeOption.textContent; option.setAttribute("role", "option"); option.setAttribute("data-value", nativeOption.value); option.setAttribute("aria-selected", nativeOption.selected ? "true" : "false"); option.classList.toggle("is-selected", nativeOption.selected); option.addEventListener("click", function () { select.value = nativeOption.value; select.dispatchEvent(new Event("change", { bubbles: true })); closeCustomSelect(wrap, true); }); option.addEventListener("keydown", function (event) { var items = Array.prototype.slice.call( custom.menu.querySelectorAll(".feedback-select-menu-option"), ); var index = items.indexOf(option); if (event.key === "ArrowDown") { event.preventDefault(); focusCustomOption(custom.menu, index + 1); } else if (event.key === "ArrowUp") { event.preventDefault(); focusCustomOption(custom.menu, index - 1); } else if (event.key === "Home") { event.preventDefault(); focusCustomOption(custom.menu, 0); } else if (event.key === "End") { event.preventDefault(); focusCustomOption(custom.menu, items.length - 1); } else if (event.key === "Escape") { event.preventDefault(); closeCustomSelect(wrap, true); } }); custom.menu.appendChild(option); }); } function syncCustomSelect(select) { var wrap = select && select._feedbackSelectWrap; var custom = wrap && wrap._feedbackSelect; if (!custom) return; var selected = select.options[select.selectedIndex]; custom.trigger.textContent = selected ? selected.textContent : select.getAttribute("aria-label") || "请选择"; custom.trigger.disabled = select.disabled; custom.trigger.classList.toggle("is-placeholder", !select.value); wrap.classList.toggle("is-disabled", select.disabled); if (select.disabled) closeCustomSelect(wrap, false); buildCustomSelectMenu(select); } function initCustomSelect(select, index) { if (!select || select._feedbackSelectWrap) return; var wrap = document.createElement("div"); var triggerButton = document.createElement("button"); var customMenu = document.createElement("div"); var menuId = (select.id || "feedback-select-" + index) + "-menu"; wrap.className = "feedback-select-wrap"; triggerButton.type = "button"; triggerButton.className = "feedback-select-trigger"; triggerButton.setAttribute("aria-haspopup", "listbox"); triggerButton.setAttribute("aria-expanded", "false"); triggerButton.setAttribute("aria-controls", menuId); triggerButton.setAttribute("aria-label", select.getAttribute("aria-label") || "请选择"); customMenu.className = "feedback-select-menu"; customMenu.id = menuId; customMenu.setAttribute("role", "listbox"); customMenu.hidden = true; select.parentNode.insertBefore(wrap, select); wrap.appendChild(select); wrap.appendChild(triggerButton); wrap.appendChild(customMenu); select.classList.add("feedback-select-native"); select.setAttribute("tabindex", "-1"); select.setAttribute("aria-hidden", "true"); select._feedbackSelectWrap = wrap; wrap._feedbackSelect = { select: select, trigger: triggerButton, menu: customMenu }; triggerButton.addEventListener("click", function () { if (wrap.classList.contains("is-open")) closeCustomSelect(wrap, false); else openCustomSelect(wrap); }); triggerButton.addEventListener("keydown", function (event) { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; event.preventDefault(); openCustomSelect(wrap); var selectedIndex = 0; var items = customMenu.querySelectorAll(".feedback-select-menu-option"); items.forEach(function (item, itemIndex) { if (item.classList.contains("is-selected")) selectedIndex = itemIndex; }); focusCustomOption(customMenu, selectedIndex); }); select.addEventListener("change", function () { syncCustomSelect(select); }); syncCustomSelect(select); } function initCustomSelects() { [provinceSelect, citySelect, operatorSelect].forEach(initCustomSelect); } function renderCityOptions(province) { if (!citySelect) return; citySelect.innerHTML = ""; var placeholderOption = document.createElement("option"); placeholderOption.value = ""; placeholderOption.textContent = "城市(选填)"; placeholderOption.selected = true; citySelect.appendChild(placeholderOption); var citySource = CITY_OPTIONS[province] || ""; var cities = citySource ? citySource.split("|") : []; cities.forEach(function (city) { var option = document.createElement("option"); option.value = city; option.textContent = city; citySelect.appendChild(option); }); citySelect.disabled = cities.length === 0; setSelectPlaceholderState(citySelect); syncCustomSelect(citySelect); } [provinceSelect, citySelect, operatorSelect].forEach(function (select) { if (!select) return; setSelectPlaceholderState(select); select.addEventListener("change", function () { setSelectPlaceholderState(select); }); }); if (provinceSelect) { provinceSelect.addEventListener("change", function () { renderCityOptions(provinceSelect.value); }); } renderCityOptions(provinceSelect ? provinceSelect.value : ""); initCustomSelects(); function toast(message, type) { if (window.AuthAPI && typeof AuthAPI.toast === "function") { AuthAPI.toast(message, type); return; } if (typeof showToast === "function") { showToast(message, type); return; } } function setSubmitting(loading) { if (!submitBtn) return; submitBtn.disabled = !!loading; submitBtn.classList.toggle("is-loading", !!loading); submitBtn.textContent = loading ? "提交中..." : defaultSubmitText; } function updateUploadState() { var count = selectedFiles.length; if (uploadEl) uploadEl.classList.toggle("is-full", count >= MAX_FILES); if (previewList) previewList.hidden = count === 0; if (fileText) { fileText.textContent = count > 0 ? "已选 " + count + "/" + MAX_FILES + " 个文件" : "支持jpg/png 最多3个文件"; } } function renderPreviews() { if (!previewList) return; previewList.innerHTML = ""; selectedFiles.forEach(function (item) { var li = document.createElement("li"); li.className = "feedback-upload-item"; li.setAttribute("data-file-id", item.id); var img = document.createElement("img"); img.src = item.url; img.alt = item.file.name || "截图预览"; var removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "feedback-upload-remove"; removeBtn.setAttribute("aria-label", "移除图片"); removeBtn.textContent = "×"; removeBtn.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); removeFile(item.id); }); li.appendChild(img); li.appendChild(removeBtn); previewList.appendChild(li); }); updateUploadState(); } function clearFiles() { selectedFiles.forEach(function (item) { if (item.url) URL.revokeObjectURL(item.url); }); selectedFiles = []; if (fileInput) fileInput.value = ""; renderPreviews(); } function removeFile(id) { var next = []; selectedFiles.forEach(function (item) { if (item.id === id) { if (item.url) URL.revokeObjectURL(item.url); } else { next.push(item); } }); selectedFiles = next; if (fileInput) fileInput.value = ""; renderPreviews(); } function isAcceptedImage(file) { if (!file) return false; if (file.type && ACCEPT_TYPES[file.type]) return true; var name = String(file.name || "").toLowerCase(); return /\.(png|jpe?g)$/.test(name); } function addFiles(fileList) { if (!fileList || !fileList.length) return; var remain = MAX_FILES - selectedFiles.length; if (remain <= 0) { toast("最多上传 " + MAX_FILES + " 张图片", "error"); return; } var files = Array.prototype.slice.call(fileList, 0); var added = 0; var skippedSize = 0; var skippedType = 0; var skippedLimit = 0; for (var i = 0; i < files.length; i++) { if (added >= remain) { skippedLimit += files.length - i; break; } var file = files[i]; if (!isAcceptedImage(file)) { skippedType += 1; continue; } if (file.size > MAX_SIZE) { skippedSize += 1; continue; } fileSeq += 1; selectedFiles.push({ id: "f" + fileSeq, file: file, url: URL.createObjectURL(file), }); added += 1; } if (skippedType) toast("仅支持 PNG / JPG 图片", "error"); if (skippedSize) toast("单张图片不能超过 5MB", "error"); if (skippedLimit) toast("最多上传 " + MAX_FILES + " 张图片", "error"); renderPreviews(); } function openMenu() { if (!root) return; root.classList.add("is-open"); if (menu) menu.hidden = false; if (trigger) trigger.setAttribute("aria-expanded", "true"); } function closeMenu() { if (!root) return; root.classList.remove("is-open"); if (menu) menu.hidden = true; if (trigger) trigger.setAttribute("aria-expanded", "false"); } function toggleMenu() { if (!root) return; if (root.classList.contains("is-open")) closeMenu(); else openMenu(); } function selectOption(option) { var value = option.getAttribute("data-value") || ""; var label = option.textContent.trim(); if (typeInput) typeInput.value = value; if (valueEl) { valueEl.textContent = label; valueEl.classList.toggle("is-placeholder", !value); } options.forEach(function (item) { var active = item === option; item.classList.toggle("is-active", active); item.setAttribute("aria-selected", active ? "true" : "false"); }); closeMenu(); } if (trigger) { trigger.addEventListener("click", function (event) { event.preventDefault(); toggleMenu(); }); } options.forEach(function (option) { option.setAttribute("aria-selected", "false"); option.addEventListener("click", function () { selectOption(option); }); }); typeChips.forEach(function (chip) { chip.addEventListener("click", function () { typeChips.forEach(function (item) { item.classList.toggle("is-active", item === chip); }); if (typeInput) { var nextType = chip.getAttribute("data-value") || "4"; typeInput.value = nextType; typeInput.setAttribute("value", nextType); } }); }); choiceGroups.forEach(function (group) { group.querySelectorAll(".feedback-chip").forEach(function (chip) { chip.addEventListener("click", function () { group.querySelectorAll(".feedback-chip").forEach(function (item) { item.classList.toggle("is-active", item === chip); }); }); }); }); function updateCounter() { if (!counter || !contentInput) return; counter.textContent = String(contentInput.value.length) + "/500"; } if (contentInput) contentInput.addEventListener("input", updateCounter); document.addEventListener("click", function (event) { if (root && !root.contains(event.target)) closeMenu(); if (!event.target.closest(".feedback-select-wrap")) closeAllCustomSelects(null); }); document.addEventListener("keydown", function (event) { if (event.key === "Escape") closeMenu(); if (event.key === "Escape") closeAllCustomSelects(null); }); if (fileInput) { fileInput.addEventListener("change", function () { addFiles(fileInput.files); fileInput.value = ""; }); } function requireLogin() { if (window.CookieUtil && CookieUtil.isLoggedIn()) return true; toast("请先登录后再提交反馈", "error"); var loginURL = "/login/return/feedback/"; if (typeof window.openLoginDialog === "function") { var opened = window.openLoginDialog(); if (opened && typeof opened.then === "function") { opened.catch(function () { window.location.href = loginURL; }); } } else { window.location.href = loginURL; } return false; } function uploadAllImages() { if (!selectedFiles.length) return Promise.resolve([]); if (!window.AuthAPI || typeof AuthAPI.uploadImage !== "function") { return Promise.reject(new Error("上传组件未加载")); } var urls = []; var chain = Promise.resolve(); selectedFiles.forEach(function (item) { chain = chain.then(function () { return AuthAPI.uploadImage(item.file).then(function (data) { var url = (data && (data.url || data.sourceNameFs)) || ""; if (!url) throw new Error("上传失败"); urls.push(url); }); }); }); return chain.then(function () { return urls; }); } if (!form) return; if (typeInput && typeChips.length) { var initialTypeChip = document.querySelector("[data-feedback-type-option].is-active") || typeChips[0]; typeInput.value = initialTypeChip.getAttribute("data-value") || "2"; } updateUploadState(); form.addEventListener("submit", function (event) { event.preventDefault(); if (!window.HttpRequest) { return; } if (submitBtn && submitBtn.classList.contains("is-loading")) return; if (!requireLogin()) return; var activeTypeChip = document.querySelector("[data-feedback-type-option].is-active"); var type = parseInt( activeTypeChip ? activeTypeChip.getAttribute("data-value") : typeInput && typeInput.value ? typeInput.value : "", 10, ); if (!(type >= 1 && type <= 4)) { toast("请选择问题类型", "error"); openMenu(); if (valueEl) { valueEl.textContent = placeholder; valueEl.classList.add("is-placeholder"); } return; } var content = contentInput ? String(contentInput.value || "").trim() : ""; if (!content) { toast("请填写问题描述", "error"); if (contentInput) contentInput.focus(); return; } if (content.length > 500) { toast("问题描述最多 500 字", "error"); return; } if (selectedFiles.length > MAX_FILES) { toast("最多上传 " + MAX_FILES + " 张图片", "error"); return; } for (var i = 0; i < selectedFiles.length; i++) { if (selectedFiles[i].file.size > MAX_SIZE) { toast("单张图片不能超过 5MB", "error"); return; } } var contact = contactInput ? String(contactInput.value || "").trim() : ""; if (!contact) { toast("请填写联系方式", "error"); if (contactInput) contactInput.focus(); return; } setSubmitting(true); uploadAllImages() .then(function (screenshots) { var body = { type: type, content: content, }; if (contact) body.contact = contact; if (screenshots && screenshots.length) body.screenshots = screenshots; return HttpRequest.post("/api/feedback/add", body); }) .then(function (res) { if (!res || res.code !== 200) { var err = new Error((res && (res.tip || res.msg)) || "提交失败"); err.code = res && res.code; err.data = res; throw err; } toast((res && res.tip) || "提交成功", "success"); form.reset(); renderCityOptions(""); setSelectPlaceholderState(provinceSelect); setSelectPlaceholderState(operatorSelect); syncCustomSelect(provinceSelect); syncCustomSelect(operatorSelect); if (typeInput) typeInput.value = ""; if (valueEl) { valueEl.textContent = placeholder; valueEl.classList.add("is-placeholder"); } options.forEach(function (item) { item.classList.remove("is-active"); item.setAttribute("aria-selected", "false"); }); typeChips.forEach(function (item, index) { item.classList.toggle("is-active", index === 0); }); if (typeInput && typeChips.length) { typeInput.value = typeChips[0].getAttribute("data-value") || "2"; } choiceGroups.forEach(function (group) { var groupChips = group.querySelectorAll(".feedback-chip"); groupChips.forEach(function (item, index) { item.classList.toggle("is-active", index === 0 && group.hasAttribute("data-default-first")); }); }); updateCounter(); clearFiles(); }) .catch(function (err) { if (err && err.code === 401) { toast("请先登录后再提交反馈", "error"); if (typeof window.openLoginDialog === "function") { var opened = window.openLoginDialog(); if (opened && typeof opened.then === "function") { opened.catch(function () { window.location.href = "/login/return/feedback/"; }); } } return; } toast((err && err.message) || "提交失败", "error"); }) .finally(function () { setSubmitting(false); }); }); })();